@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.
- package/README.md +6 -1
- package/index.ts +166 -116
- 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
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) *
|
|
681
|
-
const empty =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
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;
|