@pi-unipi/footer 2.11.0 → 2.12.1
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 +20 -3
- package/package.json +2 -2
- package/src/commands.ts +9 -15
- package/src/config.ts +2 -0
- package/src/glance-editor.ts +285 -0
- package/src/help.ts +1 -1
- package/src/index.ts +351 -27
- package/src/presets.ts +16 -1
- package/src/rendering/icons.ts +8 -0
- package/src/rendering/theme.ts +2 -0
- package/src/segments/core.ts +114 -7
- package/src/tps-tracker.ts +540 -240
- package/src/tui/settings-tui.ts +18 -2
- package/src/types.ts +4 -0
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { subscribeToEvents } from "./events.js";
|
|
|
14
14
|
import { loadFooterSettings, saveFooterSettings } from "./config.js";
|
|
15
15
|
import { getPreset } from "./presets.js";
|
|
16
16
|
import { registerCommands } from "./commands.js";
|
|
17
|
+
import { GlanceEditor } from "./glance-editor.js";
|
|
17
18
|
|
|
18
19
|
// Import segment groups
|
|
19
20
|
import { CORE_SEGMENTS } from "./segments/core.js";
|
|
@@ -64,6 +65,12 @@ export interface FooterState {
|
|
|
64
65
|
footerData: unknown;
|
|
65
66
|
tuiRef: import("@earendil-works/pi-tui").TUI | null | undefined;
|
|
66
67
|
refreshTimer: ReturnType<typeof setInterval> | null;
|
|
68
|
+
/** Glance-style editor component installed */
|
|
69
|
+
glanceInstalled: boolean;
|
|
70
|
+
/** Glance experiment active (frame input + strip, classic row suppressed) */
|
|
71
|
+
glanceMode: boolean;
|
|
72
|
+
/** Deferred install timer (focus-safety deferral past the boot overlay) */
|
|
73
|
+
glanceInstallTimer: ReturnType<typeof setTimeout> | null;
|
|
67
74
|
/** Re-register footer + widgets with pi UI (for live enable) */
|
|
68
75
|
setupUI: ((pi: ExtensionAPI, ctx: ExtensionContext) => void) | null;
|
|
69
76
|
}
|
|
@@ -87,6 +94,9 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
87
94
|
footerData: null,
|
|
88
95
|
tuiRef: null,
|
|
89
96
|
refreshTimer: null,
|
|
97
|
+
glanceInstalled: false,
|
|
98
|
+
glanceMode: true,
|
|
99
|
+
glanceInstallTimer: null,
|
|
90
100
|
setupUI: null,
|
|
91
101
|
};
|
|
92
102
|
|
|
@@ -103,11 +113,33 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
103
113
|
// 1s branch-scan in the refresh timer only reconciles persisted messages.
|
|
104
114
|
wireTpsStreamingEvents(pi);
|
|
105
115
|
|
|
116
|
+
// TTFT request boundary (harness semantics): turn_start = "agent started".
|
|
117
|
+
// Stamps the pending record's requestAt so the first delta can measure
|
|
118
|
+
// time-to-first-word from the moment the turn began.
|
|
119
|
+
pi.on("turn_start", ((event: { timestamp?: number }) => {
|
|
120
|
+
try { tpsTracker.onTurnStart(event?.timestamp); } catch { /* best-effort */ }
|
|
121
|
+
}) as (event: unknown) => void);
|
|
122
|
+
|
|
123
|
+
// Close the open turn when the agent fully settles (harness assistant/message
|
|
124
|
+
// boundary ≈ agent_settled for wall-time purposes).
|
|
125
|
+
pi.on("agent_settled", (() => {
|
|
126
|
+
try { tpsTracker.onTurnEnd(); } catch { /* best-effort */ }
|
|
127
|
+
}) as (event: unknown) => void);
|
|
128
|
+
|
|
129
|
+
// Tool wall time: call → result pairs matched by callId.
|
|
130
|
+
pi.on("tool_execution_start", ((event: { toolCallId?: string }) => {
|
|
131
|
+
try { if (event?.toolCallId) tpsTracker.onToolCallStart(event.toolCallId); } catch { /* best-effort */ }
|
|
132
|
+
}) as (event: unknown) => void);
|
|
133
|
+
pi.on("tool_execution_end", ((event: { toolCallId?: string }) => {
|
|
134
|
+
try { if (event?.toolCallId) tpsTracker.onToolCallEnd(event.toolCallId); } catch { /* best-effort */ }
|
|
135
|
+
}) as (event: unknown) => void);
|
|
136
|
+
|
|
106
137
|
// ─── Session lifecycle ──────────────────────────────────────────────────
|
|
107
138
|
|
|
108
139
|
pi.on("session_start", async (_event, ctx) => {
|
|
109
140
|
const settings = loadFooterSettings();
|
|
110
141
|
state.enabled = settings.enabled;
|
|
142
|
+
state.glanceMode = settings.glanceMode !== false; // default ON
|
|
111
143
|
state.piContext = ctx;
|
|
112
144
|
state.renderer.setPreset(settings.preset);
|
|
113
145
|
state.renderer.setActive(settings.enabled);
|
|
@@ -117,6 +149,24 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
117
149
|
// Subscribe to events
|
|
118
150
|
state.unsubscribeEvents = subscribeToEvents(pi, state.registry);
|
|
119
151
|
|
|
152
|
+
// Glance-style input surface (pi-glance-inspired). Preserves all default
|
|
153
|
+
// editor behavior via CustomEditor subclassing; only paint differs.
|
|
154
|
+
//
|
|
155
|
+
// FOCUS-SAFETY DEFERRAL: setEditorComponent() internally calls
|
|
156
|
+
// ui.setFocus(newEditor). info-screen (loaded before us) opens its boot
|
|
157
|
+
// dashboard during ITS session_start handler, so our session_start runs
|
|
158
|
+
// while that overlay owns keyboard focus. Swapping now would steal focus
|
|
159
|
+
// and strand the dashboard unclosable (q/Esc would type into the editor).
|
|
160
|
+
// The boot overlay auto-closes after ~2s; we install after a grace period
|
|
161
|
+
// longer than any sane bootTimeoutMs.
|
|
162
|
+
state.glanceInstallTimer = setTimeout(() => installGlanceEditor(state, ctx), 3500);
|
|
163
|
+
|
|
164
|
+
// Sync TPS cursor with persisted assistant messages so streaming-hook
|
|
165
|
+
// indexes match the reconciliation scan's branch-local indexes.
|
|
166
|
+
tpsTracker.reset();
|
|
167
|
+
resetTpsStreamingIndex();
|
|
168
|
+
cursorSyncCount(ctx);
|
|
169
|
+
|
|
120
170
|
// Setup footer + widgets
|
|
121
171
|
setupFooterUI(pi, ctx, state);
|
|
122
172
|
state.setupUI = (p: ExtensionAPI, c: ExtensionContext) => setupFooterUI(p, c, state);
|
|
@@ -124,6 +174,10 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
124
174
|
|
|
125
175
|
pi.on("session_shutdown", async () => {
|
|
126
176
|
state.renderer.setActive(false);
|
|
177
|
+
if (state.glanceInstallTimer) {
|
|
178
|
+
clearTimeout(state.glanceInstallTimer);
|
|
179
|
+
state.glanceInstallTimer = null;
|
|
180
|
+
}
|
|
127
181
|
state.unsubscribeEvents?.();
|
|
128
182
|
state.unsubscribeEvents = null;
|
|
129
183
|
state.piContext = null;
|
|
@@ -174,18 +228,75 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
174
228
|
const sm = (piCtx as any).sessionManager;
|
|
175
229
|
const events = sm?.getBranch?.() ?? [];
|
|
176
230
|
let msgIndex = 0;
|
|
231
|
+
let userCount = 0;
|
|
232
|
+
let firstAssistantTs = 0;
|
|
233
|
+
let lastAssistantTs = 0;
|
|
234
|
+
let prevAssistantTs = 0;
|
|
235
|
+
// Branch-derived tool time: pending callId → assistant msg ts.
|
|
236
|
+
const pendingToolCalls = new Map<string, number>();
|
|
237
|
+
let branchToolMs = 0;
|
|
177
238
|
for (const e of events) {
|
|
178
239
|
if (!e || typeof e !== "object") continue;
|
|
179
240
|
if (e.type !== "message") continue;
|
|
180
241
|
const m = e.message;
|
|
181
|
-
if (!m
|
|
242
|
+
if (!m) continue;
|
|
243
|
+
// Branch-derived turn/wall accounting: user messages delimit
|
|
244
|
+
// turns; assistant timestamps bound the session wall time.
|
|
245
|
+
const ts = Date.parse((e as any).timestamp ?? "") || 0;
|
|
246
|
+
if (m.role === "user") {
|
|
247
|
+
userCount++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (m.role === "toolResult") {
|
|
251
|
+
// Pair back to the assistant that issued this call.
|
|
252
|
+
const callId = (m as any).toolCallId as string | undefined;
|
|
253
|
+
if (callId && pendingToolCalls.has(callId)) {
|
|
254
|
+
const issuedAt = pendingToolCalls.get(callId)!;
|
|
255
|
+
pendingToolCalls.delete(callId);
|
|
256
|
+
if (ts > issuedAt) branchToolMs += Math.min(ts - issuedAt, 600_000);
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (m.role !== "assistant") continue;
|
|
182
261
|
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
|
|
262
|
+
if (ts > 0) {
|
|
263
|
+
if (firstAssistantTs === 0 || ts < firstAssistantTs) firstAssistantTs = ts;
|
|
264
|
+
if (ts > lastAssistantTs) lastAssistantTs = ts;
|
|
265
|
+
}
|
|
183
266
|
const hasStop = !!m.stopReason;
|
|
184
|
-
// Pass the whole message:
|
|
185
|
-
//
|
|
267
|
+
// Pass the whole message: completed messages get anchored to
|
|
268
|
+
// exact provider usage.output; in-flight ones density-estimated.
|
|
186
269
|
tpsTracker.onMessageUpdate(msgIndex, m, hasStop);
|
|
270
|
+
// Register this message's tool calls for result pairing.
|
|
271
|
+
// Block type is "toolCall" (capital C) in persisted sessions.
|
|
272
|
+
const content = m.content as Array<{ type?: string; id?: string }> | undefined;
|
|
273
|
+
if (Array.isArray(content)) {
|
|
274
|
+
for (const block of content) {
|
|
275
|
+
const btype = String((block as any)?.type ?? "").toLowerCase();
|
|
276
|
+
const callId = (block as any)?.id;
|
|
277
|
+
if ((btype === "toolcall" || btype === "tool_use") && typeof callId === "string" && ts > 0) {
|
|
278
|
+
pendingToolCalls.set(callId, ts);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// TTFT seed AFTER record creation: prev assistant ts ≈ request
|
|
283
|
+
// bound, own ts ≈ first output. No-ops once hooks give samples.
|
|
284
|
+
if (ts > 0) {
|
|
285
|
+
tpsTracker.seedTtftFallback(prevAssistantTs, ts, msgIndex);
|
|
286
|
+
prevAssistantTs = ts;
|
|
287
|
+
}
|
|
187
288
|
msgIndex++;
|
|
188
289
|
}
|
|
290
|
+
tpsTracker.syncBranchStats(userCount, msgIndex);
|
|
291
|
+
// NOTE: no per-record duration seeding here on purpose. Timestamps
|
|
292
|
+
// alone cannot mark stream END — a 'next-entry' delta would include
|
|
293
|
+
// tool runs + user think-time inside the rate window (the bug that
|
|
294
|
+
// made 100-tok/s models read ~7 tok/s). AVG now uses only
|
|
295
|
+
// hook-measured decode windows; see getSessionAvgTps().
|
|
296
|
+
if (lastAssistantTs > firstAssistantTs) {
|
|
297
|
+
tpsTracker.syncWallMs(lastAssistantTs - firstAssistantTs);
|
|
298
|
+
}
|
|
299
|
+
tpsTracker.syncToolMs(branchToolMs);
|
|
189
300
|
}
|
|
190
301
|
} catch {
|
|
191
302
|
// Silently ignore — TPS is best-effort
|
|
@@ -198,6 +309,11 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
198
309
|
state.renderer.setContext(state.piContext, footerData);
|
|
199
310
|
|
|
200
311
|
const unsub = footerData.onBranchChange(() => {
|
|
312
|
+
// Branch indexes are relative to the current branch. Re-sync the TPS
|
|
313
|
+
// hook cursor and rebuild tracker records for the new branch.
|
|
314
|
+
tpsTracker.reset();
|
|
315
|
+
resetTpsStreamingIndex();
|
|
316
|
+
cursorSyncCount(state.piContext);
|
|
201
317
|
state.renderer.resetLayoutCache();
|
|
202
318
|
});
|
|
203
319
|
|
|
@@ -212,7 +328,9 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
212
328
|
};
|
|
213
329
|
});
|
|
214
330
|
|
|
215
|
-
// Top row widget
|
|
331
|
+
// Top row widget — classic status line (suppressed in glance mode; the
|
|
332
|
+
// glance frame's top border already shows UNIPI │ branch and the bottom
|
|
333
|
+
// border shows context/model/thinking, so a segment row would duplicate it)
|
|
216
334
|
ctx.ui.setWidget("footer-top", (_tui, theme) => {
|
|
217
335
|
// Update the renderer's theme-like
|
|
218
336
|
const themeLike = { fg: (color: string, text: string) => theme.fg(color as any, text) };
|
|
@@ -226,8 +344,8 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
226
344
|
},
|
|
227
345
|
render(width: number): string[] {
|
|
228
346
|
if (!state.enabled || !state.piContext || width <= 0) return [];
|
|
229
|
-
|
|
230
|
-
|
|
347
|
+
// Glance mode replaces the classic segment line entirely.
|
|
348
|
+
if (state.glanceMode) return [];
|
|
231
349
|
const layout = state.renderer.computeLayout(width);
|
|
232
350
|
if (!layout.topContent) return [];
|
|
233
351
|
|
|
@@ -240,37 +358,197 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
240
358
|
};
|
|
241
359
|
}, { placement: "aboveEditor" });
|
|
242
360
|
|
|
243
|
-
// Secondary row widget
|
|
244
|
-
ctx.ui.setWidget("footer-secondary", (_tui,
|
|
361
|
+
// Secondary row widget — glance-style session strip
|
|
362
|
+
ctx.ui.setWidget("footer-secondary", (_tui, theme) => {
|
|
245
363
|
return {
|
|
246
364
|
dispose() {},
|
|
247
365
|
invalidate() {
|
|
248
366
|
state.renderer.resetLayoutCache();
|
|
249
367
|
},
|
|
250
368
|
render(width: number): string[] {
|
|
251
|
-
if (!state.enabled || !state.piContext || width <= 0) return [];
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return lines;
|
|
369
|
+
if (!state.enabled || !state.glanceMode || !state.piContext || width <= 0) return [];
|
|
370
|
+
const strip = renderSessionStrip(state.piContext);
|
|
371
|
+
if (!strip) return [];
|
|
372
|
+
// Centered under the input box.
|
|
373
|
+
const w = visibleWidth(strip);
|
|
374
|
+
if (w >= width) return [truncateToWidth(strip, width)];
|
|
375
|
+
const leftPad = Math.floor((width - w) / 2);
|
|
376
|
+
return [" ".repeat(leftPad) + strip];
|
|
263
377
|
},
|
|
264
378
|
};
|
|
265
379
|
}, { placement: "belowEditor" });
|
|
266
380
|
}
|
|
267
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Install the GlanceEditor via ctx.ui.setEditorComponent. Safe to call
|
|
384
|
+
* repeatedly; no-ops when already installed, when glance mode is off, or
|
|
385
|
+
* without a UI. Failures fall back to pi's default input box.
|
|
386
|
+
*/
|
|
387
|
+
/**
|
|
388
|
+
* Apply glanceMode live: install or remove the GlanceEditor and re-render.
|
|
389
|
+
* Called from the settings overlay's onSettingsChanged callback. Removing
|
|
390
|
+
* (setEditorComponent(undefined)) restores pi's default editor.
|
|
391
|
+
*/
|
|
392
|
+
export function applyGlanceMode(
|
|
393
|
+
st: FooterState,
|
|
394
|
+
cmdCtx: { ui: { setEditorComponent(f: never): void }; hasUI: boolean },
|
|
395
|
+
): void {
|
|
396
|
+
if (!cmdCtx.hasUI) return;
|
|
397
|
+
if (st.glanceInstallTimer) {
|
|
398
|
+
clearTimeout(st.glanceInstallTimer);
|
|
399
|
+
st.glanceInstallTimer = null;
|
|
400
|
+
}
|
|
401
|
+
if (st.glanceMode) {
|
|
402
|
+
installGlanceEditor(st, cmdCtx);
|
|
403
|
+
} else {
|
|
404
|
+
try { cmdCtx.ui.setEditorComponent(undefined as never); } catch { /* already gone */ }
|
|
405
|
+
st.glanceInstalled = false;
|
|
406
|
+
}
|
|
407
|
+
st.tuiRef?.requestRender();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function installGlanceEditor(
|
|
411
|
+
st: FooterState,
|
|
412
|
+
uiHost: { ui: { setEditorComponent(f: unknown): void } },
|
|
413
|
+
): void {
|
|
414
|
+
if (st.glanceInstalled || !st.piContext || !st.glanceMode) return;
|
|
415
|
+
try {
|
|
416
|
+
const piCtx = st.piContext as Record<string, unknown> | undefined;
|
|
417
|
+
const cwd = (piCtx?.sessionManager as any)?.getCwd?.() ?? (piCtx as any)?.cwd ?? process.cwd();
|
|
418
|
+
const workspace = String(cwd).split("/").filter(Boolean).pop() ?? "~";
|
|
419
|
+
uiHost.ui.setEditorComponent((tui: unknown, theme: unknown, keybindings: unknown) =>
|
|
420
|
+
new GlanceEditor(tui as never, theme as never, keybindings as never, () => {
|
|
421
|
+
const p = st.piContext as Record<string, unknown> | undefined;
|
|
422
|
+
const usage = typeof (p as any)?.getContextUsage === "function"
|
|
423
|
+
? (p as any).getContextUsage()
|
|
424
|
+
: undefined;
|
|
425
|
+
const model = p?.model as Record<string, unknown> | undefined;
|
|
426
|
+
let modelName = (model?.name || model?.id || "") as string;
|
|
427
|
+
if (modelName.startsWith("Claude ")) modelName = modelName.slice(7);
|
|
428
|
+
const branch = (st.footerData as any)?.getGitBranch?.() ?? null;
|
|
429
|
+
return {
|
|
430
|
+
workspace,
|
|
431
|
+
branch: typeof branch === "string" ? branch : null,
|
|
432
|
+
contextPct: typeof usage?.percent === "number" ? usage.percent : null,
|
|
433
|
+
contextWindow: typeof usage?.contextWindow === "number" ? usage.contextWindow : 0,
|
|
434
|
+
modelName,
|
|
435
|
+
thinkingLevel: typeof p?.thinkingLevel === "string" ? p.thinkingLevel : null,
|
|
436
|
+
};
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
st.glanceInstalled = true;
|
|
440
|
+
} catch {
|
|
441
|
+
st.glanceInstalled = false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── Glance session strip ──────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
/** Format ms as stopwatch duration: 00:12 / 00:12:14 (mm:ss past 1h → h:mm:ss). */
|
|
448
|
+
function fmtWall(ms: number): string {
|
|
449
|
+
if (ms < 1000) return "00:00";
|
|
450
|
+
const totalSec = Math.floor(ms / 1000);
|
|
451
|
+
const h = Math.floor(totalSec / 3600);
|
|
452
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
453
|
+
const s = totalSec % 60;
|
|
454
|
+
const mm = String(m).padStart(2, "0");
|
|
455
|
+
const ss = String(s).padStart(2, "0");
|
|
456
|
+
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Cache hit % from the session branch usage; null when no data. */
|
|
460
|
+
function cacheHitPct(piContext: unknown): number | null {
|
|
461
|
+
try {
|
|
462
|
+
let input = 0, cacheRead = 0, cacheWrite = 0;
|
|
463
|
+
const sm = (piContext as Record<string, unknown>)?.sessionManager as any;
|
|
464
|
+
for (const e of sm?.getBranch?.() ?? []) {
|
|
465
|
+
const m = e?.message;
|
|
466
|
+
if (!m || m.role !== "assistant" || !m.usage) continue;
|
|
467
|
+
input += m.usage.input ?? 0;
|
|
468
|
+
cacheRead += m.usage.cacheRead ?? 0;
|
|
469
|
+
cacheWrite += m.usage.cacheWrite ?? 0;
|
|
470
|
+
}
|
|
471
|
+
const denom = input + cacheRead + cacheWrite;
|
|
472
|
+
if (denom <= 0) return null;
|
|
473
|
+
return Math.round((cacheRead / denom) * 100);
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Basic truecolor accents for strip numbers. */
|
|
480
|
+
const STRIP_COLOR = {
|
|
481
|
+
count: "\x1b[96m", // cyan — turns/steps
|
|
482
|
+
time: "\x1b[93m", // amber — wall · tool
|
|
483
|
+
ttft: "\x1b[95m", // magenta — avg ttft
|
|
484
|
+
psGood: "\x1b[92m", // green — ≥ 30 tok/s
|
|
485
|
+
psSlow: "\x1b[91m", // red — < 10 tok/s
|
|
486
|
+
psMid: "\x1b[97m", // white — in between
|
|
487
|
+
cacheHit: "\x1b[92m", // green — high hit
|
|
488
|
+
cacheWarn: "\x1b[93m", // amber — lowish hit
|
|
489
|
+
reset: "\x1b[39m",
|
|
490
|
+
} as const;
|
|
491
|
+
|
|
492
|
+
const c = (code: string, text: string) => `${code}${text}${STRIP_COLOR.reset}`;
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Glance-style centered stats strip under the input:
|
|
496
|
+
* n Turn · n Steps | wall · tool wall | avg TTFT · n tok/s | cache n%
|
|
497
|
+
*/
|
|
498
|
+
function renderSessionStrip(piContext: unknown): string | null {
|
|
499
|
+
const parts: string[] = [];
|
|
500
|
+
|
|
501
|
+
const turns = tpsTracker.getTurnCount();
|
|
502
|
+
const steps = tpsTracker.getStepCount();
|
|
503
|
+
if (turns > 0 || steps > 0) {
|
|
504
|
+
parts.push(`${c(STRIP_COLOR.count, String(turns))} turn \u00b7 ${c(STRIP_COLOR.count, String(steps))} step${steps === 1 ? "" : "s"}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const llmMs = tpsTracker.getSessionLlmMs();
|
|
508
|
+
const toolMs = tpsTracker.getToolMs();
|
|
509
|
+
// Wall + tool time always rendered together once anything is known —
|
|
510
|
+
// '00:00 · tool 00:00' beats a silently missing slot mid-strip.
|
|
511
|
+
if (turns > 0 || steps > 0) {
|
|
512
|
+
parts.push(`${c(STRIP_COLOR.time, fmtWall(llmMs))} \u00b7 tool ${c(STRIP_COLOR.time, fmtWall(toolMs))}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const ttft = tpsTracker.getAvgTtftMs();
|
|
516
|
+
let avgTps = tpsTracker.getSessionAvgTps();
|
|
517
|
+
const tpsColor = avgTps >= 30 ? STRIP_COLOR.psGood : avgTps < 10 ? STRIP_COLOR.psSlow : STRIP_COLOR.psMid;
|
|
518
|
+
const avgTpsLabel = avgTps >= 100
|
|
519
|
+
? String(Math.round(avgTps))
|
|
520
|
+
: avgTps > 0
|
|
521
|
+
? avgTps.toFixed(1)
|
|
522
|
+
: "0";
|
|
523
|
+
if (ttft !== null || steps > 0) {
|
|
524
|
+
const seg = [
|
|
525
|
+
ttft !== null ? c(STRIP_COLOR.ttft, ttft >= 10000 ? `${Math.round(ttft / 1000)}s` : `${ttft}ms`) + " avg ttft" : null,
|
|
526
|
+
steps > 0 ? c(tpsColor, `${avgTpsLabel} tok/s`) : null,
|
|
527
|
+
].filter(Boolean).join(" \u00b7 ");
|
|
528
|
+
if (seg) parts.push(seg);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const hit = cacheHitPct(piContext);
|
|
532
|
+
if (hit !== null) {
|
|
533
|
+
const hitColor = hit >= 70 ? STRIP_COLOR.cacheHit : STRIP_COLOR.cacheWarn;
|
|
534
|
+
parts.push(c(hitColor, `${hit}% cache hit`));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (parts.length === 0) return null;
|
|
538
|
+
return parts.join(" | ");
|
|
539
|
+
}
|
|
540
|
+
|
|
268
541
|
// ─── TPS streaming-event hooks ──────────────────────────────────────────────
|
|
269
542
|
|
|
270
543
|
/**
|
|
271
|
-
* Sequential index of the currently-streaming assistant message
|
|
272
|
-
* locally because pi does not expose a stable message
|
|
273
|
-
* events, and the TPS tracker keys records off this index.
|
|
544
|
+
* Sequential index of the currently-streaming assistant message within the
|
|
545
|
+
* session branch. Tracked locally because pi does not expose a stable message
|
|
546
|
+
* index on streaming events, and the TPS tracker keys records off this index.
|
|
547
|
+
*
|
|
548
|
+
* Both the streaming hooks and the 1s reconciliation scan key records by the
|
|
549
|
+
* same scheme: position among assistant messages in `getBranch()`. To stay in
|
|
550
|
+
* sync, on session_start (and branch changes) we replay the count of persisted
|
|
551
|
+
* assistant messages into this cursor BEFORE any new message_start fires.
|
|
274
552
|
*/
|
|
275
553
|
let tpsStreamingIndex = -1;
|
|
276
554
|
|
|
@@ -279,6 +557,45 @@ function resetTpsStreamingIndex(): void {
|
|
|
279
557
|
tpsStreamingIndex = -1;
|
|
280
558
|
}
|
|
281
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Re-synchronize the streaming hook cursor with the session branch.
|
|
562
|
+
*
|
|
563
|
+
* Root cause of the frozen-TPS bug: this module's cursor starts at -1 for every
|
|
564
|
+
* new session, while the 1s reconciliation scan seeds tracker records at
|
|
565
|
+
* branch-local indexes 0..N-1. Until the cursor caught up past N, every
|
|
566
|
+
* streaming event landed on an already-completed record and was ignored —
|
|
567
|
+
* so live TPS froze at the last completed message for the first N messages of
|
|
568
|
+
* each session (and drifted permanently once cursors diverged).
|
|
569
|
+
*
|
|
570
|
+
* Fix: on session_start, seed the cursor to N-1 (count of persisted assistant
|
|
571
|
+
* messages minus one), so the NEXT message_start maps to branch-local index N,
|
|
572
|
+
* exactly matching what the reconciliation scan will use. Also called when a
|
|
573
|
+
* branch change is observed (compaction/branch switch), since indexes are
|
|
574
|
+
* branch-relative.
|
|
575
|
+
*/
|
|
576
|
+
export function cursorSyncCount(piContext: unknown): void {
|
|
577
|
+
try {
|
|
578
|
+
const ctx = piContext as Record<string, unknown> | undefined;
|
|
579
|
+
const sm = ctx?.sessionManager as { getBranch?: () => unknown[] } | undefined;
|
|
580
|
+
const events = sm?.getBranch?.() ?? [];
|
|
581
|
+
let assistantCount = 0;
|
|
582
|
+
for (const e of events) {
|
|
583
|
+
if (!e || typeof e !== "object") continue;
|
|
584
|
+
const entry = e as Record<string, unknown>;
|
|
585
|
+
if (entry.type !== "message") continue;
|
|
586
|
+
const m = entry.message as Record<string, unknown> | undefined;
|
|
587
|
+
if (!m || m.role !== "assistant") continue;
|
|
588
|
+
const stopReason = m.stopReason as string | undefined;
|
|
589
|
+
if (stopReason === "error" || stopReason === "aborted") continue;
|
|
590
|
+
assistantCount++;
|
|
591
|
+
}
|
|
592
|
+
tpsStreamingIndex = assistantCount - 1;
|
|
593
|
+
} catch {
|
|
594
|
+
// Cursor sync is best-effort; streaming hooks tolerate being behind via
|
|
595
|
+
// the reconciliation scan anyway.
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
282
599
|
/**
|
|
283
600
|
* Subscribe to pi's message streaming events and feed the TPS tracker in real
|
|
284
601
|
* time. This complements the 1s branch-scan in the refresh timer, which only
|
|
@@ -299,14 +616,20 @@ function wireTpsStreamingEvents(pi: ExtensionAPI): void {
|
|
|
299
616
|
if (!m || m.role !== "assistant") return;
|
|
300
617
|
if (m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
301
618
|
tpsStreamingIndex++;
|
|
302
|
-
tpsTracker.
|
|
619
|
+
tpsTracker.onMessageStart(tpsStreamingIndex);
|
|
303
620
|
})) as (event: unknown) => void);
|
|
304
621
|
|
|
305
|
-
pi.on("message_update", ((event: { message: unknown }) => safe(() => {
|
|
622
|
+
pi.on("message_update", ((event: { message: unknown; assistantMessageEvent?: { type?: string; delta?: string } }) => safe(() => {
|
|
306
623
|
if (tpsStreamingIndex < 0) return;
|
|
307
624
|
const m = event.message as Record<string, unknown> | undefined;
|
|
308
625
|
if (!m || m.role !== "assistant") return;
|
|
309
|
-
|
|
626
|
+
// Incremental deltas keep counting O(chunk); the clock starts on the
|
|
627
|
+
// FIRST delta so time-to-first-token is excluded from the rate window.
|
|
628
|
+
const ev = event.assistantMessageEvent;
|
|
629
|
+
const type = ev?.type;
|
|
630
|
+
if ((type === "text_delta" || type === "thinking_delta" || type === "toolcall_delta") && typeof ev?.delta === "string") {
|
|
631
|
+
tpsTracker.onStreamingDelta(tpsStreamingIndex, ev.delta);
|
|
632
|
+
}
|
|
310
633
|
})) as (event: unknown) => void);
|
|
311
634
|
|
|
312
635
|
pi.on("message_end", ((event: { message: unknown }) => safe(() => {
|
|
@@ -314,6 +637,7 @@ function wireTpsStreamingEvents(pi: ExtensionAPI): void {
|
|
|
314
637
|
const m = event.message as Record<string, unknown> | undefined;
|
|
315
638
|
if (!m || m.role !== "assistant") return;
|
|
316
639
|
if (m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
317
|
-
|
|
640
|
+
// Anchor to exact provider usage.output at stream end.
|
|
641
|
+
tpsTracker.onMessageEnd(tpsStreamingIndex, m);
|
|
318
642
|
})) as (event: unknown) => void);
|
|
319
643
|
}
|
package/src/presets.ts
CHANGED
|
@@ -12,8 +12,22 @@
|
|
|
12
12
|
import type { PresetDef, SeparatorStyle, ColorScheme } from "./types.js";
|
|
13
13
|
import { getDefaultColors } from "./rendering/theme.js";
|
|
14
14
|
|
|
15
|
-
/** Default preset —
|
|
15
|
+
/** Default preset — glance-style status line + branded head */
|
|
16
16
|
const DEFAULT_PRESET: PresetDef = {
|
|
17
|
+
leftSegments: [
|
|
18
|
+
"uni", "model", "thinking_level", "directory", "git",
|
|
19
|
+
],
|
|
20
|
+
rightSegments: [
|
|
21
|
+
"context_pct", "tokens_total",
|
|
22
|
+
"tps", "cost",
|
|
23
|
+
"clock", "duration",
|
|
24
|
+
],
|
|
25
|
+
secondarySegments: [],
|
|
26
|
+
colors: getDefaultColors(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Classic preset — the pre-v3 balanced view (kept for /unipi:footer) */
|
|
30
|
+
const CLASSIC_PRESET: PresetDef = {
|
|
17
31
|
leftSegments: [
|
|
18
32
|
"model", "api_state", "tool_count", "git",
|
|
19
33
|
],
|
|
@@ -96,6 +110,7 @@ const ASCII_PRESET: PresetDef = {
|
|
|
96
110
|
/** All preset definitions */
|
|
97
111
|
export const PRESETS: Record<string, PresetDef> = {
|
|
98
112
|
default: DEFAULT_PRESET,
|
|
113
|
+
classic: CLASSIC_PRESET,
|
|
99
114
|
minimal: MINIMAL_PRESET,
|
|
100
115
|
compact: COMPACT_PRESET,
|
|
101
116
|
full: FULL_PRESET,
|
package/src/rendering/icons.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface IconSet {
|
|
|
31
31
|
clock: string;
|
|
32
32
|
duration: string;
|
|
33
33
|
thinkingLevel: string;
|
|
34
|
+
brand: string;
|
|
35
|
+
directory: string;
|
|
34
36
|
|
|
35
37
|
// Compactor segments
|
|
36
38
|
sessionEvents: string;
|
|
@@ -84,6 +86,8 @@ export interface IconSet {
|
|
|
84
86
|
/** Nerd Font glyphs — requires a Nerd Font installed in the terminal */
|
|
85
87
|
export const NERD_ICONS: IconSet = {
|
|
86
88
|
// Core
|
|
89
|
+
brand: "",
|
|
90
|
+
directory: "\u{F1154}",
|
|
87
91
|
model: "\u{F06A9}", //
|
|
88
92
|
apiState: "\u{F109B}", //
|
|
89
93
|
toolCount: "\u{F1064}", //
|
|
@@ -169,6 +173,8 @@ export const EMOJI_ICONS: IconSet = {
|
|
|
169
173
|
clock: "🕔",
|
|
170
174
|
duration: "⏱",
|
|
171
175
|
thinkingLevel: "💡",
|
|
176
|
+
brand: "",
|
|
177
|
+
directory: "📁",
|
|
172
178
|
|
|
173
179
|
// Compactor
|
|
174
180
|
sessionEvents: "📈",
|
|
@@ -238,6 +244,8 @@ export const TEXT_ICONS: IconSet = {
|
|
|
238
244
|
clock: "CLK",
|
|
239
245
|
duration: "DUR",
|
|
240
246
|
thinkingLevel: "THK",
|
|
247
|
+
brand: "",
|
|
248
|
+
directory: "DIR",
|
|
241
249
|
|
|
242
250
|
// Compactor
|
|
243
251
|
sessionEvents: "EVT",
|
package/src/rendering/theme.ts
CHANGED
|
@@ -179,7 +179,9 @@ export function mutedPlaceholder(text: string): string {
|
|
|
179
179
|
/** Default semantic-to-theme-color mapping */
|
|
180
180
|
const DEFAULT_COLOR_MAP: Record<SemanticColor, ThemeColor | `#${string}`> = {
|
|
181
181
|
// ── Model & Identity (Left zone) ──
|
|
182
|
+
brand: "accent",
|
|
182
183
|
model: "#c792ea", // Soft purple — model name
|
|
184
|
+
directory: "#61afef", // Blue — current directory
|
|
183
185
|
path: "text",
|
|
184
186
|
git: "#82cc6f", // Green (clean default)
|
|
185
187
|
gitClean: "#82cc6f", // Green — clean branch
|