@firstpick/pi-extension-git-footer-status 0.1.0
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/LICENSE +21 -0
- package/README.md +35 -0
- package/index.ts +531 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Firstpick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# pi-extension-git-footer-status
|
|
2
|
+
|
|
3
|
+
Enhanced Pi footer with git health and model/token telemetry.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Shows compact runtime metrics in the footer:
|
|
8
|
+
- input/output/cache tokens
|
|
9
|
+
- token speed estimate (`tok/s`)
|
|
10
|
+
- cost + context-window usage
|
|
11
|
+
- current model and reasoning level
|
|
12
|
+
- Shows git status context on the path line:
|
|
13
|
+
- branch/detached state
|
|
14
|
+
- ahead/behind
|
|
15
|
+
- staged/unstaged/untracked/conflicts
|
|
16
|
+
- operation state (rebase/merge/cherry-pick/revert/bisect)
|
|
17
|
+
- stash/submodule/worktree/tag/last-commit-age/signing mismatch indicators
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:@firstpick/pi-extension-git-footer-status
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
No required configuration.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
- `/git-footer-refresh` — refresh git/footer information immediately.
|
|
32
|
+
|
|
33
|
+
## Tools
|
|
34
|
+
|
|
35
|
+
None.
|
package/index.ts
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
8
|
+
type GitSnapshot = {
|
|
9
|
+
branch: string;
|
|
10
|
+
isDetached: boolean;
|
|
11
|
+
ahead: number;
|
|
12
|
+
behind: number;
|
|
13
|
+
staged: number;
|
|
14
|
+
unstaged: number;
|
|
15
|
+
untracked: number;
|
|
16
|
+
conflicted: number;
|
|
17
|
+
operation?: string;
|
|
18
|
+
stashCount: number;
|
|
19
|
+
submoduleDirty: number;
|
|
20
|
+
lastCommitAge?: string;
|
|
21
|
+
worktreeCount: number;
|
|
22
|
+
headTag?: string;
|
|
23
|
+
signingMismatch: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Toggle footer items on/off here.
|
|
27
|
+
const FOOTER_FLAGS = {
|
|
28
|
+
branch: false,
|
|
29
|
+
detachedIndicator: true,
|
|
30
|
+
operationState: true,
|
|
31
|
+
|
|
32
|
+
ahead: true,
|
|
33
|
+
behind: true,
|
|
34
|
+
|
|
35
|
+
staged: true,
|
|
36
|
+
unstaged: true,
|
|
37
|
+
untracked: true,
|
|
38
|
+
conflicted: true,
|
|
39
|
+
clean: true,
|
|
40
|
+
|
|
41
|
+
stash: true,
|
|
42
|
+
submodules: true,
|
|
43
|
+
worktrees: true,
|
|
44
|
+
tag: true,
|
|
45
|
+
lastCommitAge: true,
|
|
46
|
+
signingMismatch: true,
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
function formatCwd(cwd: string): string {
|
|
50
|
+
const home = homedir();
|
|
51
|
+
if (cwd === home) return "~";
|
|
52
|
+
if (cwd.startsWith(`${home}/`)) return `~/${cwd.slice(home.length + 1)}`;
|
|
53
|
+
return cwd;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatTokens(count: number): string {
|
|
57
|
+
if (count < 1000) return count.toString();
|
|
58
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
59
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
60
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
61
|
+
return `${Math.round(count / 1000000)}M`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeTimestampMs(timestamp: number): number {
|
|
65
|
+
// Handle mixed timestamp units from different session formats.
|
|
66
|
+
// seconds -> ms (e.g. 1715000000)
|
|
67
|
+
// ms -> ms (e.g. 1715000000000)
|
|
68
|
+
// microsec -> ms (e.g. 1715000000000000)
|
|
69
|
+
if (timestamp < 1e11) return timestamp * 1000;
|
|
70
|
+
if (timestamp > 1e14) return Math.floor(timestamp / 1000);
|
|
71
|
+
return timestamp;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getEntryTimestampMs(entry: { type: string; timestamp: string; message?: { timestamp?: number } }): number | null {
|
|
75
|
+
if (entry.type === "message" && typeof entry.message?.timestamp === "number") {
|
|
76
|
+
return normalizeTimestampMs(entry.message.timestamp);
|
|
77
|
+
}
|
|
78
|
+
const parsed = Date.parse(entry.timestamp);
|
|
79
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatTokenSpeed(tokensPerSecond: number): string {
|
|
83
|
+
if (tokensPerSecond < 100) {
|
|
84
|
+
if (tokensPerSecond >= 10) return tokensPerSecond.toFixed(1);
|
|
85
|
+
return tokensPerSecond.toFixed(2);
|
|
86
|
+
}
|
|
87
|
+
if (tokensPerSecond < 1000) return Math.round(tokensPerSecond).toString();
|
|
88
|
+
if (tokensPerSecond < 10000) return `${(tokensPerSecond / 1000).toFixed(1)}k`;
|
|
89
|
+
if (tokensPerSecond < 1000000) return `${Math.round(tokensPerSecond / 1000)}k`;
|
|
90
|
+
if (tokensPerSecond < 10000000) return `${(tokensPerSecond / 1000000).toFixed(1)}M`;
|
|
91
|
+
return `${Math.round(tokensPerSecond / 1000000)}M`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runGit(pi: ExtensionAPI, cwd: string, args: string[], timeout = 2000): Promise<string | undefined> {
|
|
95
|
+
const result = await pi.exec("git", args, { cwd, timeout }).catch(() => undefined);
|
|
96
|
+
if (!result || result.code !== 0) return undefined;
|
|
97
|
+
return result.stdout.trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
101
|
+
try {
|
|
102
|
+
await access(path);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toAgeLabel(epochSeconds: number): string | undefined {
|
|
110
|
+
if (!Number.isFinite(epochSeconds) || epochSeconds <= 0) return undefined;
|
|
111
|
+
|
|
112
|
+
const deltaSeconds = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds);
|
|
113
|
+
if (deltaSeconds < 60) return "now";
|
|
114
|
+
|
|
115
|
+
const minutes = Math.floor(deltaSeconds / 60);
|
|
116
|
+
if (minutes < 60) return `${minutes}m`;
|
|
117
|
+
|
|
118
|
+
const hours = Math.floor(minutes / 60);
|
|
119
|
+
if (hours < 24) return `${hours}h`;
|
|
120
|
+
|
|
121
|
+
const days = Math.floor(hours / 24);
|
|
122
|
+
return `${days}d`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function detectGitOperation(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
|
|
126
|
+
const gitDirRaw = await runGit(pi, cwd, ["rev-parse", "--git-dir"]);
|
|
127
|
+
if (!gitDirRaw) return undefined;
|
|
128
|
+
|
|
129
|
+
const gitDir = isAbsolute(gitDirRaw) ? gitDirRaw : resolve(cwd, gitDirRaw);
|
|
130
|
+
|
|
131
|
+
if ((await pathExists(resolve(gitDir, "rebase-merge"))) || (await pathExists(resolve(gitDir, "rebase-apply")))) {
|
|
132
|
+
return "REBASING";
|
|
133
|
+
}
|
|
134
|
+
if (await pathExists(resolve(gitDir, "MERGE_HEAD"))) return "MERGING";
|
|
135
|
+
if (await pathExists(resolve(gitDir, "CHERRY_PICK_HEAD"))) return "CHERRY-PICK";
|
|
136
|
+
if (await pathExists(resolve(gitDir, "REVERT_HEAD"))) return "REVERTING";
|
|
137
|
+
if (await pathExists(resolve(gitDir, "BISECT_LOG"))) return "BISECT";
|
|
138
|
+
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readGitSnapshot(pi: ExtensionAPI, cwd: string): Promise<GitSnapshot | null> {
|
|
143
|
+
const result = await pi
|
|
144
|
+
.exec("git", ["status", "--porcelain=2", "--branch"], { cwd, timeout: 3000 })
|
|
145
|
+
.catch(() => undefined);
|
|
146
|
+
|
|
147
|
+
if (!result || result.code !== 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let branch = "";
|
|
152
|
+
let detachedOid: string | undefined;
|
|
153
|
+
let ahead = 0;
|
|
154
|
+
let behind = 0;
|
|
155
|
+
let staged = 0;
|
|
156
|
+
let unstaged = 0;
|
|
157
|
+
let untracked = 0;
|
|
158
|
+
let conflicted = 0;
|
|
159
|
+
|
|
160
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
161
|
+
if (!line) continue;
|
|
162
|
+
|
|
163
|
+
if (line.startsWith("# branch.head ")) {
|
|
164
|
+
branch = line.slice("# branch.head ".length).trim();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (line.startsWith("# branch.oid ")) {
|
|
169
|
+
const oid = line.slice("# branch.oid ".length).trim();
|
|
170
|
+
if (oid && oid !== "(initial)") detachedOid = oid;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (line.startsWith("# branch.ab ")) {
|
|
175
|
+
const match = line.match(/\+(\d+)\s+-(\d+)/);
|
|
176
|
+
if (match) {
|
|
177
|
+
ahead = Number.parseInt(match[1] ?? "0", 10) || 0;
|
|
178
|
+
behind = Number.parseInt(match[2] ?? "0", 10) || 0;
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
184
|
+
const xy = line.split(" ")[1] ?? "..";
|
|
185
|
+
const x = xy[0] ?? ".";
|
|
186
|
+
const y = xy[1] ?? ".";
|
|
187
|
+
if (x !== ".") staged++;
|
|
188
|
+
if (y !== ".") unstaged++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (line.startsWith("u ")) {
|
|
193
|
+
conflicted++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (line.startsWith("? ")) {
|
|
198
|
+
untracked++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const isDetached = !branch || branch === "(detached)";
|
|
204
|
+
const resolvedBranch =
|
|
205
|
+
!isDetached
|
|
206
|
+
? branch
|
|
207
|
+
: detachedOid
|
|
208
|
+
? `detached@${detachedOid.slice(0, 7)}`
|
|
209
|
+
: "detached";
|
|
210
|
+
|
|
211
|
+
const [operation, stashList, submoduleStatus, lastCommitTs, worktreeList, headTags, commitSignRequiredRaw, headSignState] =
|
|
212
|
+
await Promise.all([
|
|
213
|
+
detectGitOperation(pi, cwd),
|
|
214
|
+
runGit(pi, cwd, ["stash", "list", "--format=%gd"]),
|
|
215
|
+
runGit(pi, cwd, ["submodule", "status", "--recursive"]),
|
|
216
|
+
runGit(pi, cwd, ["log", "-1", "--format=%ct"]),
|
|
217
|
+
runGit(pi, cwd, ["worktree", "list", "--porcelain"]),
|
|
218
|
+
runGit(pi, cwd, ["tag", "--points-at", "HEAD", "--sort=-creatordate"]),
|
|
219
|
+
runGit(pi, cwd, ["config", "--bool", "--get", "commit.gpgsign"]),
|
|
220
|
+
runGit(pi, cwd, ["log", "-1", "--format=%G?"]),
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const stashCount = stashList ? stashList.split(/\r?\n/).filter(Boolean).length : 0;
|
|
224
|
+
|
|
225
|
+
const submoduleDirty = submoduleStatus
|
|
226
|
+
? submoduleStatus
|
|
227
|
+
.split(/\r?\n/)
|
|
228
|
+
.filter((line) => line && !line.startsWith(" "))
|
|
229
|
+
.length
|
|
230
|
+
: 0;
|
|
231
|
+
|
|
232
|
+
const worktreeCount = worktreeList
|
|
233
|
+
? Math.max(
|
|
234
|
+
1,
|
|
235
|
+
worktreeList
|
|
236
|
+
.split(/\r?\n/)
|
|
237
|
+
.filter((line) => line.startsWith("worktree ")).length,
|
|
238
|
+
)
|
|
239
|
+
: 1;
|
|
240
|
+
|
|
241
|
+
const headTag = headTags?.split(/\r?\n/).find(Boolean);
|
|
242
|
+
|
|
243
|
+
const lastCommitAge = lastCommitTs ? toAgeLabel(Number.parseInt(lastCommitTs, 10)) : undefined;
|
|
244
|
+
|
|
245
|
+
const commitSignRequired = commitSignRequiredRaw?.toLowerCase() === "true";
|
|
246
|
+
const signState = headSignState?.trim().toUpperCase();
|
|
247
|
+
const signingMismatch =
|
|
248
|
+
commitSignRequired &&
|
|
249
|
+
(!signState || signState === "N" || signState === "E");
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
branch: resolvedBranch,
|
|
253
|
+
isDetached,
|
|
254
|
+
ahead,
|
|
255
|
+
behind,
|
|
256
|
+
staged,
|
|
257
|
+
unstaged,
|
|
258
|
+
untracked,
|
|
259
|
+
conflicted,
|
|
260
|
+
operation,
|
|
261
|
+
stashCount,
|
|
262
|
+
submoduleDirty,
|
|
263
|
+
lastCommitAge,
|
|
264
|
+
worktreeCount,
|
|
265
|
+
headTag,
|
|
266
|
+
signingMismatch,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildStatusText(ctx: ExtensionContext, snapshot: GitSnapshot): string {
|
|
271
|
+
const t = ctx.ui.theme;
|
|
272
|
+
const f = FOOTER_FLAGS;
|
|
273
|
+
|
|
274
|
+
const sectionSep = t.fg("dim", "│");
|
|
275
|
+
const itemSep = t.fg("dim", "·");
|
|
276
|
+
|
|
277
|
+
const branchSection: string[] = [];
|
|
278
|
+
if (f.branch) {
|
|
279
|
+
branchSection.push(t.fg("accent", ""), t.fg("accent", snapshot.branch));
|
|
280
|
+
}
|
|
281
|
+
if (f.detachedIndicator && snapshot.isDetached) branchSection.push(t.fg("warning", "⎇"));
|
|
282
|
+
if (f.operationState && snapshot.operation) branchSection.push(t.fg("warning", snapshot.operation));
|
|
283
|
+
|
|
284
|
+
const syncSection: string[] = [];
|
|
285
|
+
if (f.ahead && snapshot.ahead > 0) syncSection.push(t.fg("muted", `⇡${snapshot.ahead}`));
|
|
286
|
+
if (f.behind && snapshot.behind > 0) syncSection.push(t.fg("muted", `⇣${snapshot.behind}`));
|
|
287
|
+
|
|
288
|
+
const changesSection: string[] = [];
|
|
289
|
+
if (f.staged && snapshot.staged > 0) changesSection.push(t.fg("success", `+${snapshot.staged}`));
|
|
290
|
+
if (f.unstaged && snapshot.unstaged > 0) changesSection.push(t.fg("warning", `✎${snapshot.unstaged}`));
|
|
291
|
+
if (f.untracked && snapshot.untracked > 0) changesSection.push(t.fg("info", `◌${snapshot.untracked}`));
|
|
292
|
+
if (f.conflicted && snapshot.conflicted > 0) changesSection.push(t.fg("error", `!${snapshot.conflicted}`));
|
|
293
|
+
|
|
294
|
+
const extraSection: string[] = [];
|
|
295
|
+
if (f.stash && snapshot.stashCount > 0) extraSection.push(t.fg("muted", `⚑${snapshot.stashCount}`));
|
|
296
|
+
if (f.submodules && snapshot.submoduleDirty > 0) extraSection.push(t.fg("warning", `✖${snapshot.submoduleDirty}`));
|
|
297
|
+
if (f.worktrees && snapshot.worktreeCount > 1) extraSection.push(t.fg("muted", `📦${snapshot.worktreeCount}`));
|
|
298
|
+
if (f.tag && snapshot.headTag) extraSection.push(t.fg("accent", `🏷${snapshot.headTag}`));
|
|
299
|
+
if (f.lastCommitAge && snapshot.lastCommitAge) extraSection.push(t.fg("dim", `⏱${snapshot.lastCommitAge}`));
|
|
300
|
+
if (f.signingMismatch && snapshot.signingMismatch) extraSection.push(t.fg("warning", "🔒!"));
|
|
301
|
+
|
|
302
|
+
const isWorkingTreeClean =
|
|
303
|
+
snapshot.ahead === 0 &&
|
|
304
|
+
snapshot.behind === 0 &&
|
|
305
|
+
snapshot.staged === 0 &&
|
|
306
|
+
snapshot.unstaged === 0 &&
|
|
307
|
+
snapshot.untracked === 0 &&
|
|
308
|
+
snapshot.conflicted === 0;
|
|
309
|
+
|
|
310
|
+
if (f.clean && isWorkingTreeClean) {
|
|
311
|
+
changesSection.push(t.fg("dim", "clean"));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const sections = [branchSection, syncSection, changesSection, extraSection].filter(
|
|
315
|
+
(section) => section.length > 0,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
return sections.length > 0
|
|
319
|
+
? sections.map((section) => section.join(` ${itemSep} `)).join(` ${sectionSep} `)
|
|
320
|
+
: t.fg("dim", "git");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export default function gitFooterStatus(pi: ExtensionAPI) {
|
|
324
|
+
let refreshing = false;
|
|
325
|
+
|
|
326
|
+
const refresh = async (ctx: ExtensionContext) => {
|
|
327
|
+
if (refreshing) return;
|
|
328
|
+
refreshing = true;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const snapshot = await readGitSnapshot(pi, ctx.cwd);
|
|
332
|
+
if (!snapshot) {
|
|
333
|
+
ctx.ui.setStatus("git-footer", undefined);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
ctx.ui.setStatus("git-footer", buildStatusText(ctx, snapshot));
|
|
338
|
+
} finally {
|
|
339
|
+
refreshing = false;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
344
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
345
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
dispose: unsub,
|
|
349
|
+
invalidate() {},
|
|
350
|
+
render(width: number): string[] {
|
|
351
|
+
let totalInput = 0;
|
|
352
|
+
let totalOutput = 0;
|
|
353
|
+
let totalCacheRead = 0;
|
|
354
|
+
let totalCacheWrite = 0;
|
|
355
|
+
let totalCost = 0;
|
|
356
|
+
let latestTokenSpeed: number | null = null;
|
|
357
|
+
|
|
358
|
+
const entries = ctx.sessionManager.getEntries();
|
|
359
|
+
for (let i = 0; i < entries.length; i++) {
|
|
360
|
+
const entry = entries[i];
|
|
361
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
362
|
+
const message = entry.message as AssistantMessage;
|
|
363
|
+
totalInput += message.usage?.input ?? 0;
|
|
364
|
+
totalOutput += message.usage?.output ?? 0;
|
|
365
|
+
totalCacheRead += message.usage?.cacheRead ?? 0;
|
|
366
|
+
totalCacheWrite += message.usage?.cacheWrite ?? 0;
|
|
367
|
+
totalCost += message.usage?.cost?.total ?? 0;
|
|
368
|
+
|
|
369
|
+
if ((message.usage?.output ?? 0) > 0) {
|
|
370
|
+
const endMs = getEntryTimestampMs(entry);
|
|
371
|
+
if (endMs !== null) {
|
|
372
|
+
let fallbackSpeed: number | null = null;
|
|
373
|
+
|
|
374
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
375
|
+
const previous = entries[j];
|
|
376
|
+
if (previous.type !== "message") continue;
|
|
377
|
+
|
|
378
|
+
// Skip assistant-to-assistant deltas (too noisy for speed).
|
|
379
|
+
if (previous.message.role === "assistant") continue;
|
|
380
|
+
|
|
381
|
+
const startMs = getEntryTimestampMs(previous);
|
|
382
|
+
if (startMs === null || endMs <= startMs) continue;
|
|
383
|
+
|
|
384
|
+
const elapsedSeconds = (endMs - startMs) / 1000;
|
|
385
|
+
if (elapsedSeconds <= 0) continue;
|
|
386
|
+
|
|
387
|
+
const speed = (message.usage?.output ?? 0) / elapsedSeconds;
|
|
388
|
+
|
|
389
|
+
// Prefer user-anchored speed (best approximation of full turn latency).
|
|
390
|
+
if (previous.message.role === "user") {
|
|
391
|
+
latestTokenSpeed = speed;
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Keep first non-assistant speed as fallback if no user message is found.
|
|
396
|
+
if (fallbackSpeed === null) fallbackSpeed = speed;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (latestTokenSpeed === null && fallbackSpeed !== null) {
|
|
400
|
+
latestTokenSpeed = fallbackSpeed;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const contextUsage = ctx.getContextUsage();
|
|
408
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
409
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
410
|
+
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
|
|
411
|
+
const contextPercentDisplay =
|
|
412
|
+
contextPercent === "?" ? `?/${formatTokens(contextWindow)}` : `${contextPercent}%/${formatTokens(contextWindow)}`;
|
|
413
|
+
|
|
414
|
+
let contextPercentStr: string;
|
|
415
|
+
if (contextPercent === "?") {
|
|
416
|
+
contextPercentStr = theme.fg("dim", contextPercentDisplay);
|
|
417
|
+
} else if (contextPercentValue < 50) {
|
|
418
|
+
contextPercentStr = theme.fg("success", contextPercentDisplay);
|
|
419
|
+
} else if (contextPercentValue < 65) {
|
|
420
|
+
contextPercentStr = theme.fg("accent", contextPercentDisplay);
|
|
421
|
+
} else if (contextPercentValue < 75) {
|
|
422
|
+
contextPercentStr = theme.fg("muted", contextPercentDisplay);
|
|
423
|
+
} else if (contextPercentValue < 85) {
|
|
424
|
+
contextPercentStr = theme.fg("warning", contextPercentDisplay);
|
|
425
|
+
} else {
|
|
426
|
+
contextPercentStr = theme.fg("error", contextPercentDisplay);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const sectionSep = theme.fg("dim", "│");
|
|
430
|
+
const itemSep = theme.fg("dim", "·");
|
|
431
|
+
|
|
432
|
+
const ioItems: string[] = [];
|
|
433
|
+
if (totalInput) ioItems.push(`↑${formatTokens(totalInput)}`);
|
|
434
|
+
if (totalOutput) ioItems.push(`↓${formatTokens(totalOutput)}`);
|
|
435
|
+
|
|
436
|
+
const cacheItems: string[] = [];
|
|
437
|
+
if (totalCacheRead) cacheItems.push(`R${formatTokens(totalCacheRead)}`);
|
|
438
|
+
if (totalCacheWrite) cacheItems.push(`W${formatTokens(totalCacheWrite)}`);
|
|
439
|
+
|
|
440
|
+
const segments: string[] = [];
|
|
441
|
+
if (ioItems.length > 0) segments.push(`${theme.fg("muted", "🪙")} ${ioItems.join(` ${itemSep} `)}`);
|
|
442
|
+
if (cacheItems.length > 0) segments.push(`${theme.fg("muted", "💾")} ${cacheItems.join(` ${itemSep} `)}`);
|
|
443
|
+
if (latestTokenSpeed !== null) segments.push(`⚡ ${formatTokenSpeed(latestTokenSpeed)} tok/s`);
|
|
444
|
+
|
|
445
|
+
const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
|
|
446
|
+
if (totalCost || usingSubscription) {
|
|
447
|
+
segments.push(`${theme.fg("muted", "💸")} $${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
segments.push(`${theme.fg("muted", "🧠")} ${contextPercentStr}`);
|
|
451
|
+
|
|
452
|
+
let statsLeft = segments.join(` ${sectionSep} `);
|
|
453
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
454
|
+
if (statsLeftWidth > width) {
|
|
455
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
456
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const modelName = ctx.model?.id || "no-model";
|
|
460
|
+
const thinkingLevel = pi.getThinkingLevel();
|
|
461
|
+
const rightSideWithoutProvider =
|
|
462
|
+
ctx.model?.reasoning
|
|
463
|
+
? thinkingLevel === "off"
|
|
464
|
+
? `${modelName} • thinking off`
|
|
465
|
+
: `${modelName} • ${thinkingLevel}`
|
|
466
|
+
: modelName;
|
|
467
|
+
|
|
468
|
+
let rightSide = rightSideWithoutProvider;
|
|
469
|
+
if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
|
|
470
|
+
const withProvider = `(${ctx.model.provider}) ${rightSideWithoutProvider}`;
|
|
471
|
+
if (statsLeftWidth + 2 + visibleWidth(withProvider) <= width) {
|
|
472
|
+
rightSide = withProvider;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
477
|
+
const totalNeeded = statsLeftWidth + 2 + rightSideWidth;
|
|
478
|
+
let tokenLine: string;
|
|
479
|
+
|
|
480
|
+
if (totalNeeded <= width) {
|
|
481
|
+
const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
|
|
482
|
+
tokenLine = statsLeft + padding + rightSide;
|
|
483
|
+
} else {
|
|
484
|
+
const availableForRight = width - statsLeftWidth - 2;
|
|
485
|
+
if (availableForRight > 0) {
|
|
486
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
487
|
+
const truncatedRightWidth = visibleWidth(truncatedRight);
|
|
488
|
+
const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));
|
|
489
|
+
tokenLine = statsLeft + padding + truncatedRight;
|
|
490
|
+
} else {
|
|
491
|
+
tokenLine = statsLeft;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const branch = footerData.getGitBranch();
|
|
496
|
+
const cwdWithBranch = `${formatCwd(ctx.cwd)}${branch ? ` (${branch})` : ""}`;
|
|
497
|
+
const cwdText = theme.fg("muted", cwdWithBranch);
|
|
498
|
+
const status = footerData.getExtensionStatuses().get("git-footer");
|
|
499
|
+
const pathGitLine = status ? `${cwdText}${theme.fg("dim", " │ ")}${status}` : cwdText;
|
|
500
|
+
|
|
501
|
+
// Keep default subtle-grey look even when parts contain their own ANSI colors.
|
|
502
|
+
// Wrapping the whole line once is not enough because inner color resets cancel outer dim.
|
|
503
|
+
const dimStatsLeft = theme.fg("dim", statsLeft);
|
|
504
|
+
const remainder = tokenLine.slice(statsLeft.length);
|
|
505
|
+
const dimRemainder = theme.fg("dim", remainder);
|
|
506
|
+
|
|
507
|
+
return [truncateToWidth(dimStatsLeft + dimRemainder, width), truncateToWidth(pathGitLine, width)];
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
await refresh(ctx);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
516
|
+
await refresh(ctx);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
520
|
+
ctx.ui.setStatus("git-footer", undefined);
|
|
521
|
+
ctx.ui.setFooter(undefined);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
pi.registerCommand("git-footer-refresh", {
|
|
525
|
+
description: "Refresh git footer information",
|
|
526
|
+
handler: async (_args, ctx) => {
|
|
527
|
+
await refresh(ctx);
|
|
528
|
+
ctx.ui.notify("Git footer refreshed", "info");
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@firstpick/pi-extension-git-footer-status",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Enhanced Pi footer with git status, token usage, context usage, and model telemetry.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"extension"
|
|
11
|
+
],
|
|
12
|
+
"pi": {
|
|
13
|
+
"extensions": [
|
|
14
|
+
"./index.ts"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
19
|
+
"@mariozechner/pi-ai": "*",
|
|
20
|
+
"@mariozechner/pi-tui": "*"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.ts",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
]
|
|
27
|
+
}
|