@ogulcancelik/pi-minimal-footer 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -1
  2. package/index.ts +166 -116
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ Minimal footer for [pi](https://github.com/badlogic/pi-mono) that replaces the d
11
11
  - **Context gauge** — working directory, git branch, model, thinking level, and context window usage with token counts
12
12
  - **Subscription usage bars** — rolling window quotas with reset timers for supported providers
13
13
  - **Auto-refresh** — fetches usage on startup and model switch, then every 5 minutes
14
- - **Git integration** — branch name, dirty state, ahead/behind counts (supports jj)
14
+ - **Git integration** — branch name, dirty state, ahead/behind counts
15
15
 
16
16
  ## Supported providers
17
17
 
@@ -39,6 +39,11 @@ Usage is fetched:
39
39
  - Immediately on model switch (Ctrl+P)
40
40
  - Every 5 minutes after that
41
41
 
42
+ Git state is refreshed:
43
+ - Once on startup
44
+ - When pi reports a branch change
45
+ - At the end of each turn
46
+
42
47
  The footer adapts to narrow terminals by stacking lines vertically instead of the single-line wide layout.
43
48
 
44
49
  ## Known issues
package/index.ts CHANGED
@@ -35,7 +35,6 @@ interface GitCache {
35
35
  dirty: boolean;
36
36
  ahead: number;
37
37
  behind: number;
38
- isJj: boolean;
39
38
  }
40
39
 
41
40
  // ============ Usage Cache ============
@@ -51,63 +50,58 @@ let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved
51
50
 
52
51
  let gitCache: GitCache | null = null;
53
52
 
54
- function refreshGitCache(): void {
55
- gitCache = null;
53
+ function parseGitStatus(output: string): GitCache {
54
+ let branch: string | null = null;
55
+ let dirty = false;
56
+ let ahead = 0;
57
+ let behind = 0;
56
58
 
57
- try {
58
- const gitRoot = execSync("git rev-parse --show-toplevel 2>/dev/null", {
59
- encoding: "utf8",
60
- timeout: 500,
61
- }).trim();
62
-
63
- if (!gitRoot) return;
64
-
65
- const isJj = existsSync(`${gitRoot}/.jj`);
66
- let branch: string | null = null;
67
-
68
- if (isJj) {
69
- try {
70
- const jjBranch = execSync(
71
- `jj log -r 'heads(ancestors(@) & bookmarks())' --no-graph -T 'bookmarks.map(|b| b.name()).join(" ")' --limit 1 2>/dev/null`,
72
- { encoding: "utf8", timeout: 1000 }
73
- ).trim();
74
- branch = jjBranch ? jjBranch.split(" ")[0] : null;
75
- } catch {}
76
- } else {
77
- try {
78
- const gitBranch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", {
79
- encoding: "utf8",
80
- timeout: 500,
81
- }).trim();
82
- branch = gitBranch && gitBranch !== "HEAD" ? gitBranch : null;
83
- } catch {}
84
- }
85
-
86
- let dirty = false;
87
- try {
88
- const status = execSync("git status --porcelain 2>/dev/null", {
89
- encoding: "utf8",
90
- timeout: 500,
91
- });
92
- dirty = status.trim().length > 0;
93
- } catch {}
59
+ for (const line of output.split("\n")) {
60
+ if (!line) continue;
94
61
 
95
- let ahead = 0;
96
- let behind = 0;
97
- try {
98
- const counts = execSync("git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null", {
99
- encoding: "utf8",
100
- timeout: 500,
101
- }).trim();
102
- const [a, b] = counts.split(/\s+/);
103
- ahead = parseInt(a, 10) || 0;
104
- behind = parseInt(b, 10) || 0;
105
- } catch {}
62
+ if (line.startsWith("# branch.head ")) {
63
+ const head = line.slice("# branch.head ".length).trim();
64
+ branch = head && head !== "(detached)" ? head : null;
65
+ continue;
66
+ }
67
+
68
+ if (line.startsWith("# branch.ab ")) {
69
+ const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/);
70
+ if (match) {
71
+ ahead = parseInt(match[1], 10) || 0;
72
+ behind = parseInt(match[2], 10) || 0;
73
+ }
74
+ continue;
75
+ }
76
+
77
+ if (!line.startsWith("# ")) dirty = true;
78
+ }
79
+
80
+ return { branch, dirty, ahead, behind };
81
+ }
106
82
 
107
- gitCache = { branch, dirty, ahead, behind, isJj };
83
+ function sameGitCache(a: GitCache | null, b: GitCache | null): boolean {
84
+ if (a === b) return true;
85
+ if (!a || !b) return false;
86
+ return a.branch === b.branch && a.dirty === b.dirty && a.ahead === b.ahead && a.behind === b.behind;
87
+ }
88
+
89
+ function refreshGitCache(): boolean {
90
+ let next: GitCache | null = null;
91
+
92
+ try {
93
+ const status = execSync("git status --porcelain=v2 --branch 2>/dev/null", {
94
+ encoding: "utf8",
95
+ timeout: 1000,
96
+ });
97
+ next = parseGitStatus(status.trimEnd());
108
98
  } catch {
109
- gitCache = null;
99
+ next = null;
110
100
  }
101
+
102
+ const changed = !sameGitCache(gitCache, next);
103
+ gitCache = next;
104
+ return changed;
111
105
  }
112
106
 
113
107
  // ============ JWT Helpers ============
@@ -675,10 +669,54 @@ export default function (pi: ExtensionAPI) {
675
669
  return `${tokens}`;
676
670
  }
677
671
 
678
- function renderContextGauge(percentage: number, theme: any, used?: number, total?: number): string {
672
+ function fitFooterSegment(width: number, variants: string[]): string {
673
+ const safeWidth = Math.max(1, width);
674
+
675
+ for (const variant of variants) {
676
+ if (visibleWidth(variant) <= safeWidth) return variant;
677
+ }
678
+
679
+ return truncateToWidth(variants[variants.length - 1] || "", safeWidth);
680
+ }
681
+
682
+ function wrapFooterSegments(segments: string[], width: number, sep: string): string[] {
683
+ const safeWidth = Math.max(1, width);
684
+ const lines: string[] = [];
685
+ let current = "";
686
+
687
+ for (const segment of segments.filter(Boolean)) {
688
+ const fitted = truncateToWidth(segment, safeWidth);
689
+
690
+ if (!current) {
691
+ current = fitted;
692
+ continue;
693
+ }
694
+
695
+ const candidate = current + sep + fitted;
696
+ if (visibleWidth(candidate) <= safeWidth) {
697
+ current = candidate;
698
+ continue;
699
+ }
700
+
701
+ lines.push(truncateToWidth(current, safeWidth));
702
+ current = fitted;
703
+ }
704
+
705
+ if (current) lines.push(truncateToWidth(current, safeWidth));
706
+ return lines;
707
+ }
708
+
709
+ function renderContextGauge(
710
+ percentage: number,
711
+ theme: any,
712
+ used?: number,
713
+ total?: number,
714
+ options?: { barWidth?: number; includeCounts?: boolean }
715
+ ): string {
716
+ const barWidth = Math.max(4, options?.barWidth ?? CTX_GAUGE_WIDTH);
679
717
  const clamped = Math.max(0, Math.min(100, percentage));
680
- const filled = Math.round((clamped / 100) * CTX_GAUGE_WIDTH);
681
- const empty = CTX_GAUGE_WIDTH - filled;
718
+ const filled = Math.round((clamped / 100) * barWidth);
719
+ const empty = barWidth - filled;
682
720
 
683
721
  let color: string;
684
722
  if (clamped >= 90) color = "error";
@@ -688,7 +726,10 @@ export default function (pi: ExtensionAPI) {
688
726
 
689
727
  const bar = theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
690
728
  const pct = `${Math.round(clamped)}%`;
691
- const counts = used !== undefined && total ? ` ${formatTokenCount(used)}/${formatTokenCount(total)}` : "";
729
+ const counts =
730
+ options?.includeCounts === false || used === undefined || !total
731
+ ? ""
732
+ : ` ${formatTokenCount(used)}/${formatTokenCount(total)}`;
692
733
 
693
734
  return theme.fg("dim", "ctx ") + bar + " " + theme.fg("dim", pct + counts);
694
735
  }
@@ -706,38 +747,38 @@ export default function (pi: ExtensionAPI) {
706
747
  return theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
707
748
  }
708
749
 
750
+ function renderUsageWindow(
751
+ window: RateWindow,
752
+ theme: any,
753
+ options?: { barWidth?: number; includeReset?: boolean }
754
+ ): string {
755
+ const dim = (s: string) => theme.fg("dim", s);
756
+ const bar = renderUsageBar(window.usedPercent, Math.max(4, options?.barWidth ?? 10), theme);
757
+ const pct = dim(`${Math.round(window.usedPercent)}%`);
758
+ const timeStr = options?.includeReset === false || !window.resetsIn ? "" : " " + dim(window.resetsIn);
759
+ return `${dim(window.label)} ${bar} ${pct}${timeStr}`;
760
+ }
761
+
709
762
  function renderUsageLine(usage: UsageSnapshot, width: number, theme: any): string[] {
710
763
  if (!usage.windows.length) return [];
711
764
 
712
765
  const dim = (s: string) => theme.fg("dim", s);
713
766
  const sep = " " + dim(">") + " ";
714
- const USAGE_BAR_WIDTH = 10;
715
- const NARROW_THRESHOLD = 60;
716
-
717
- if (width < NARROW_THRESHOLD) {
718
- // Narrow: one line per window
719
- const lines: string[] = [];
720
- for (const w of usage.windows) {
721
- const bar = renderUsageBar(w.usedPercent, USAGE_BAR_WIDTH, theme);
722
- const pct = dim(`${Math.round(w.usedPercent)}%`);
723
- const timeStr = w.resetsIn ? " " + dim(w.resetsIn) : "";
724
- lines.push(`${dim(w.label)} ${bar} ${pct}${timeStr}`);
725
- }
726
- return lines;
727
- }
728
-
729
- // Wide: single line with > separators, fixed-width bars
730
- // Format: "Codex > 5h ━━━━━━━━━━ 46% 2h38m > 7d ━━━━━━━━━━ 46% 2d1h"
731
- const parts: string[] = [theme.fg("accent", usage.provider)];
767
+ const segments: string[] = [theme.fg("accent", usage.provider)];
732
768
 
733
769
  for (const w of usage.windows) {
734
- const bar = renderUsageBar(w.usedPercent, USAGE_BAR_WIDTH, theme);
735
- const pct = dim(`${Math.round(w.usedPercent)}%`);
736
- const timeStr = w.resetsIn ? " " + dim(w.resetsIn) : "";
737
- parts.push(`${dim(w.label)} ${bar} ${pct}${timeStr}`);
770
+ segments.push(
771
+ fitFooterSegment(width, [
772
+ renderUsageWindow(w, theme, { barWidth: 10, includeReset: true }),
773
+ renderUsageWindow(w, theme, { barWidth: 8, includeReset: true }),
774
+ renderUsageWindow(w, theme, { barWidth: 8, includeReset: false }),
775
+ renderUsageWindow(w, theme, { barWidth: 6, includeReset: false }),
776
+ renderUsageWindow(w, theme, { barWidth: 4, includeReset: false }),
777
+ ])
778
+ );
738
779
  }
739
780
 
740
- return [parts.join(sep)];
781
+ return wrapFooterSegments(segments, width, sep);
741
782
  }
742
783
 
743
784
  function getThinkingLevel(ctx: any): string {
@@ -760,11 +801,10 @@ export default function (pi: ExtensionAPI) {
760
801
  const lastAssistant = messages
761
802
  .slice()
762
803
  .reverse()
763
- .find((m: any) => m.role === "assistant" && m.stopReason !== "aborted");
804
+ .find((m: any) => m.role === "assistant" && m.stopReason !== "aborted") as any;
764
805
 
765
- if (!lastAssistant?.usage) return { percentage: 0, used: 0, total: contextWindow };
766
-
767
- const usage = lastAssistant.usage;
806
+ const usage = lastAssistant?.usage;
807
+ if (!usage) return { percentage: 0, used: 0, total: contextWindow };
768
808
  const contextTokens = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
769
809
 
770
810
  return { percentage: (contextTokens / contextWindow) * 100, used: contextTokens, total: contextWindow };
@@ -778,6 +818,10 @@ export default function (pi: ExtensionAPI) {
778
818
  // Store tui reference for triggering re-renders from event handlers
779
819
  let tuiRef: { requestRender: () => void } | null = null;
780
820
 
821
+ function refreshGitFooter(): void {
822
+ if (refreshGitCache()) tuiRef?.requestRender();
823
+ }
824
+
781
825
  /** Fetch usage for the active provider. Shows cached data immediately,
782
826
  * then fetches fresh in the background. Discards results if provider
783
827
  * changed while the fetch was in flight. */
@@ -848,8 +892,7 @@ export default function (pi: ExtensionAPI) {
848
892
  tuiRef = tui;
849
893
 
850
894
  const unsub = footerData.onBranchChange(() => {
851
- refreshGitCache();
852
- tui.requestRender();
895
+ refreshGitFooter();
853
896
  });
854
897
 
855
898
  // Initial fetch inside factory — tui is guaranteed available here,
@@ -887,7 +930,8 @@ export default function (pi: ExtensionAPI) {
887
930
 
888
931
  // Model + thinking
889
932
  const modelName = ctx.model?.id?.split("/").pop() || "no-model";
890
- let modelStr = theme.fg("muted", modelName);
933
+ const plainModelStr = theme.fg("muted", modelName);
934
+ let modelStr = plainModelStr;
891
935
  if (ctx.model?.reasoning) {
892
936
  const thinkingLevel = getThinkingLevel(ctx);
893
937
  if (thinkingLevel !== "off") {
@@ -895,47 +939,53 @@ export default function (pi: ExtensionAPI) {
895
939
  }
896
940
  }
897
941
 
898
- // Context gauge
899
- const gauge = renderContextGauge(percentage, theme, ctxUsed, ctxTotal);
900
-
901
942
  const sep = " " + theme.fg("dim", ">") + " ";
902
- const NARROW_THRESHOLD = 60;
903
-
904
943
  const lines: string[] = [];
905
-
906
- if (width < NARROW_THRESHOLD) {
907
- // Narrow: stack vertically
908
- const pwdColored = theme.fg("accent", pwd);
909
- if (branchStr) {
910
- lines.push(truncateToWidth(pwdColored + sep + branchStr, width));
911
- } else {
912
- lines.push(truncateToWidth(pwdColored, width));
913
- }
914
- lines.push(truncateToWidth(modelStr, width));
915
- lines.push(truncateToWidth(gauge, width));
916
- } else {
917
- // Wide: single line
918
- const pwdColored = theme.fg("accent", pwd);
919
- const branchPart = branchStr ? sep + branchStr : "";
920
- const modelPart = sep + modelStr;
921
- const gaugePart = sep + gauge;
922
- lines.push(truncateToWidth(pwdColored + branchPart + modelPart + gaugePart, width));
923
- }
944
+ const pwdStr = theme.fg("accent", pwd);
945
+ const statusBlocks = [
946
+ fitFooterSegment(width, branchStr ? [pwdStr + sep + branchStr, pwdStr] : [pwdStr]),
947
+ fitFooterSegment(width, modelStr === plainModelStr ? [plainModelStr] : [modelStr, plainModelStr]),
948
+ fitFooterSegment(width, [
949
+ renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
950
+ barWidth: CTX_GAUGE_WIDTH,
951
+ includeCounts: true,
952
+ }),
953
+ renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
954
+ barWidth: 10,
955
+ includeCounts: false,
956
+ }),
957
+ renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
958
+ barWidth: 8,
959
+ includeCounts: false,
960
+ }),
961
+ renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
962
+ barWidth: 6,
963
+ includeCounts: false,
964
+ }),
965
+ renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
966
+ barWidth: 4,
967
+ includeCounts: false,
968
+ }),
969
+ ]),
970
+ ];
971
+
972
+ lines.push(...wrapFooterSegments(statusBlocks, width, sep));
924
973
 
925
974
  if (latestUsage && latestUsage.windows.length > 0) {
926
- const usageLines = renderUsageLine(latestUsage, width, theme);
927
- for (const line of usageLines) {
928
- lines.push(truncateToWidth(line, width));
929
- }
975
+ lines.push(...renderUsageLine(latestUsage, width, theme));
930
976
  }
931
977
 
932
- return lines;
978
+ return lines.map((line) => truncateToWidth(line, width));
933
979
  },
934
980
  };
935
981
  });
936
982
 
937
983
  });
938
984
 
985
+ pi.on("turn_end", async () => {
986
+ refreshGitFooter();
987
+ });
988
+
939
989
  // Refresh when model changes — fetch immediately, restart timer
940
990
  pi.on("model_select", (event, _ctx) => {
941
991
  if (!event.model?.provider) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogulcancelik/pi-minimal-footer",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Minimal footer for pi with context gauge and subscription usage bars",
5
5
  "type": "module",
6
6
  "keywords": [