@pi-unipi/footer 2.10.2 → 2.12.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/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 +360 -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 +529 -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,84 @@ 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;
|
|
238
|
+
// Per-assistant-message duration (this assistant ts → next entry
|
|
239
|
+
// ts): the honest generation+tool window, replaces first-scan
|
|
240
|
+
// reconstruction for restart tok/s.
|
|
241
|
+
const recordDurations = new Map<number, number>();
|
|
242
|
+
let prevEntryTs = 0;
|
|
243
|
+
let prevAssistantIdx = -1;
|
|
177
244
|
for (const e of events) {
|
|
178
245
|
if (!e || typeof e !== "object") continue;
|
|
179
246
|
if (e.type !== "message") continue;
|
|
180
247
|
const m = e.message;
|
|
181
|
-
if (!m
|
|
248
|
+
if (!m) continue;
|
|
249
|
+
// Branch-derived turn/wall accounting: user messages delimit
|
|
250
|
+
// turns; assistant timestamps bound the session wall time.
|
|
251
|
+
const ts = Date.parse((e as any).timestamp ?? "") || 0;
|
|
252
|
+
// Close the previous assistant's window with this entry's ts.
|
|
253
|
+
if (prevAssistantIdx >= 0 && ts > 0 && prevEntryTs > 0) {
|
|
254
|
+
recordDurations.set(prevAssistantIdx, Math.min(ts - prevEntryTs, 600_000));
|
|
255
|
+
prevAssistantIdx = -1;
|
|
256
|
+
}
|
|
257
|
+
if (ts > 0) prevEntryTs = ts;
|
|
258
|
+
if (m.role === "user") {
|
|
259
|
+
userCount++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (m.role === "toolResult") {
|
|
263
|
+
// Pair back to the assistant that issued this call.
|
|
264
|
+
const callId = (m as any).toolCallId as string | undefined;
|
|
265
|
+
if (callId && pendingToolCalls.has(callId)) {
|
|
266
|
+
const issuedAt = pendingToolCalls.get(callId)!;
|
|
267
|
+
pendingToolCalls.delete(callId);
|
|
268
|
+
if (ts > issuedAt) branchToolMs += Math.min(ts - issuedAt, 600_000);
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (m.role !== "assistant") continue;
|
|
182
273
|
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
|
|
274
|
+
if (ts > 0) {
|
|
275
|
+
if (firstAssistantTs === 0 || ts < firstAssistantTs) firstAssistantTs = ts;
|
|
276
|
+
if (ts > lastAssistantTs) lastAssistantTs = ts;
|
|
277
|
+
prevAssistantIdx = msgIndex; // close on next entry
|
|
278
|
+
}
|
|
183
279
|
const hasStop = !!m.stopReason;
|
|
184
|
-
// Pass the whole message:
|
|
185
|
-
//
|
|
280
|
+
// Pass the whole message: completed messages get anchored to
|
|
281
|
+
// exact provider usage.output; in-flight ones density-estimated.
|
|
186
282
|
tpsTracker.onMessageUpdate(msgIndex, m, hasStop);
|
|
283
|
+
// Register this message's tool calls for result pairing.
|
|
284
|
+
// Block type is "toolCall" (capital C) in persisted sessions.
|
|
285
|
+
const content = m.content as Array<{ type?: string; id?: string }> | undefined;
|
|
286
|
+
if (Array.isArray(content)) {
|
|
287
|
+
for (const block of content) {
|
|
288
|
+
const btype = String((block as any)?.type ?? "").toLowerCase();
|
|
289
|
+
const callId = (block as any)?.id;
|
|
290
|
+
if ((btype === "toolcall" || btype === "tool_use") && typeof callId === "string" && ts > 0) {
|
|
291
|
+
pendingToolCalls.set(callId, ts);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// TTFT seed AFTER record creation: prev assistant ts ≈ request
|
|
296
|
+
// bound, own ts ≈ first output. No-ops once hooks give samples.
|
|
297
|
+
if (ts > 0) {
|
|
298
|
+
tpsTracker.seedTtftFallback(prevAssistantTs, ts, msgIndex);
|
|
299
|
+
prevAssistantTs = ts;
|
|
300
|
+
}
|
|
187
301
|
msgIndex++;
|
|
188
302
|
}
|
|
303
|
+
tpsTracker.syncBranchStats(userCount, msgIndex);
|
|
304
|
+
if (recordDurations.size > 0) tpsTracker.syncRecordDurations(recordDurations);
|
|
305
|
+
if (lastAssistantTs > firstAssistantTs) {
|
|
306
|
+
tpsTracker.syncWallMs(lastAssistantTs - firstAssistantTs);
|
|
307
|
+
}
|
|
308
|
+
tpsTracker.syncToolMs(branchToolMs);
|
|
189
309
|
}
|
|
190
310
|
} catch {
|
|
191
311
|
// Silently ignore — TPS is best-effort
|
|
@@ -198,6 +318,11 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
198
318
|
state.renderer.setContext(state.piContext, footerData);
|
|
199
319
|
|
|
200
320
|
const unsub = footerData.onBranchChange(() => {
|
|
321
|
+
// Branch indexes are relative to the current branch. Re-sync the TPS
|
|
322
|
+
// hook cursor and rebuild tracker records for the new branch.
|
|
323
|
+
tpsTracker.reset();
|
|
324
|
+
resetTpsStreamingIndex();
|
|
325
|
+
cursorSyncCount(state.piContext);
|
|
201
326
|
state.renderer.resetLayoutCache();
|
|
202
327
|
});
|
|
203
328
|
|
|
@@ -212,7 +337,9 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
212
337
|
};
|
|
213
338
|
});
|
|
214
339
|
|
|
215
|
-
// Top row widget
|
|
340
|
+
// Top row widget — classic status line (suppressed in glance mode; the
|
|
341
|
+
// glance frame's top border already shows UNIPI │ branch and the bottom
|
|
342
|
+
// border shows context/model/thinking, so a segment row would duplicate it)
|
|
216
343
|
ctx.ui.setWidget("footer-top", (_tui, theme) => {
|
|
217
344
|
// Update the renderer's theme-like
|
|
218
345
|
const themeLike = { fg: (color: string, text: string) => theme.fg(color as any, text) };
|
|
@@ -226,8 +353,8 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
226
353
|
},
|
|
227
354
|
render(width: number): string[] {
|
|
228
355
|
if (!state.enabled || !state.piContext || width <= 0) return [];
|
|
229
|
-
|
|
230
|
-
|
|
356
|
+
// Glance mode replaces the classic segment line entirely.
|
|
357
|
+
if (state.glanceMode) return [];
|
|
231
358
|
const layout = state.renderer.computeLayout(width);
|
|
232
359
|
if (!layout.topContent) return [];
|
|
233
360
|
|
|
@@ -240,37 +367,197 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
240
367
|
};
|
|
241
368
|
}, { placement: "aboveEditor" });
|
|
242
369
|
|
|
243
|
-
// Secondary row widget
|
|
244
|
-
ctx.ui.setWidget("footer-secondary", (_tui,
|
|
370
|
+
// Secondary row widget — glance-style session strip
|
|
371
|
+
ctx.ui.setWidget("footer-secondary", (_tui, theme) => {
|
|
245
372
|
return {
|
|
246
373
|
dispose() {},
|
|
247
374
|
invalidate() {
|
|
248
375
|
state.renderer.resetLayoutCache();
|
|
249
376
|
},
|
|
250
377
|
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;
|
|
378
|
+
if (!state.enabled || !state.glanceMode || !state.piContext || width <= 0) return [];
|
|
379
|
+
const strip = renderSessionStrip(state.piContext);
|
|
380
|
+
if (!strip) return [];
|
|
381
|
+
// Centered under the input box.
|
|
382
|
+
const w = visibleWidth(strip);
|
|
383
|
+
if (w >= width) return [truncateToWidth(strip, width)];
|
|
384
|
+
const leftPad = Math.floor((width - w) / 2);
|
|
385
|
+
return [" ".repeat(leftPad) + strip];
|
|
263
386
|
},
|
|
264
387
|
};
|
|
265
388
|
}, { placement: "belowEditor" });
|
|
266
389
|
}
|
|
267
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Install the GlanceEditor via ctx.ui.setEditorComponent. Safe to call
|
|
393
|
+
* repeatedly; no-ops when already installed, when glance mode is off, or
|
|
394
|
+
* without a UI. Failures fall back to pi's default input box.
|
|
395
|
+
*/
|
|
396
|
+
/**
|
|
397
|
+
* Apply glanceMode live: install or remove the GlanceEditor and re-render.
|
|
398
|
+
* Called from the settings overlay's onSettingsChanged callback. Removing
|
|
399
|
+
* (setEditorComponent(undefined)) restores pi's default editor.
|
|
400
|
+
*/
|
|
401
|
+
export function applyGlanceMode(
|
|
402
|
+
st: FooterState,
|
|
403
|
+
cmdCtx: { ui: { setEditorComponent(f: never): void }; hasUI: boolean },
|
|
404
|
+
): void {
|
|
405
|
+
if (!cmdCtx.hasUI) return;
|
|
406
|
+
if (st.glanceInstallTimer) {
|
|
407
|
+
clearTimeout(st.glanceInstallTimer);
|
|
408
|
+
st.glanceInstallTimer = null;
|
|
409
|
+
}
|
|
410
|
+
if (st.glanceMode) {
|
|
411
|
+
installGlanceEditor(st, cmdCtx);
|
|
412
|
+
} else {
|
|
413
|
+
try { cmdCtx.ui.setEditorComponent(undefined as never); } catch { /* already gone */ }
|
|
414
|
+
st.glanceInstalled = false;
|
|
415
|
+
}
|
|
416
|
+
st.tuiRef?.requestRender();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function installGlanceEditor(
|
|
420
|
+
st: FooterState,
|
|
421
|
+
uiHost: { ui: { setEditorComponent(f: unknown): void } },
|
|
422
|
+
): void {
|
|
423
|
+
if (st.glanceInstalled || !st.piContext || !st.glanceMode) return;
|
|
424
|
+
try {
|
|
425
|
+
const piCtx = st.piContext as Record<string, unknown> | undefined;
|
|
426
|
+
const cwd = (piCtx?.sessionManager as any)?.getCwd?.() ?? (piCtx as any)?.cwd ?? process.cwd();
|
|
427
|
+
const workspace = String(cwd).split("/").filter(Boolean).pop() ?? "~";
|
|
428
|
+
uiHost.ui.setEditorComponent((tui: unknown, theme: unknown, keybindings: unknown) =>
|
|
429
|
+
new GlanceEditor(tui as never, theme as never, keybindings as never, () => {
|
|
430
|
+
const p = st.piContext as Record<string, unknown> | undefined;
|
|
431
|
+
const usage = typeof (p as any)?.getContextUsage === "function"
|
|
432
|
+
? (p as any).getContextUsage()
|
|
433
|
+
: undefined;
|
|
434
|
+
const model = p?.model as Record<string, unknown> | undefined;
|
|
435
|
+
let modelName = (model?.name || model?.id || "") as string;
|
|
436
|
+
if (modelName.startsWith("Claude ")) modelName = modelName.slice(7);
|
|
437
|
+
const branch = (st.footerData as any)?.getGitBranch?.() ?? null;
|
|
438
|
+
return {
|
|
439
|
+
workspace,
|
|
440
|
+
branch: typeof branch === "string" ? branch : null,
|
|
441
|
+
contextPct: typeof usage?.percent === "number" ? usage.percent : null,
|
|
442
|
+
contextWindow: typeof usage?.contextWindow === "number" ? usage.contextWindow : 0,
|
|
443
|
+
modelName,
|
|
444
|
+
thinkingLevel: typeof p?.thinkingLevel === "string" ? p.thinkingLevel : null,
|
|
445
|
+
};
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
st.glanceInstalled = true;
|
|
449
|
+
} catch {
|
|
450
|
+
st.glanceInstalled = false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ─── Glance session strip ──────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
/** Format ms as stopwatch duration: 00:12 / 00:12:14 (mm:ss past 1h → h:mm:ss). */
|
|
457
|
+
function fmtWall(ms: number): string {
|
|
458
|
+
if (ms < 1000) return "00:00";
|
|
459
|
+
const totalSec = Math.floor(ms / 1000);
|
|
460
|
+
const h = Math.floor(totalSec / 3600);
|
|
461
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
462
|
+
const s = totalSec % 60;
|
|
463
|
+
const mm = String(m).padStart(2, "0");
|
|
464
|
+
const ss = String(s).padStart(2, "0");
|
|
465
|
+
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Cache hit % from the session branch usage; null when no data. */
|
|
469
|
+
function cacheHitPct(piContext: unknown): number | null {
|
|
470
|
+
try {
|
|
471
|
+
let input = 0, cacheRead = 0, cacheWrite = 0;
|
|
472
|
+
const sm = (piContext as Record<string, unknown>)?.sessionManager as any;
|
|
473
|
+
for (const e of sm?.getBranch?.() ?? []) {
|
|
474
|
+
const m = e?.message;
|
|
475
|
+
if (!m || m.role !== "assistant" || !m.usage) continue;
|
|
476
|
+
input += m.usage.input ?? 0;
|
|
477
|
+
cacheRead += m.usage.cacheRead ?? 0;
|
|
478
|
+
cacheWrite += m.usage.cacheWrite ?? 0;
|
|
479
|
+
}
|
|
480
|
+
const denom = input + cacheRead + cacheWrite;
|
|
481
|
+
if (denom <= 0) return null;
|
|
482
|
+
return Math.round((cacheRead / denom) * 100);
|
|
483
|
+
} catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Basic truecolor accents for strip numbers. */
|
|
489
|
+
const STRIP_COLOR = {
|
|
490
|
+
count: "\x1b[96m", // cyan — turns/steps
|
|
491
|
+
time: "\x1b[93m", // amber — wall · tool
|
|
492
|
+
ttft: "\x1b[95m", // magenta — avg ttft
|
|
493
|
+
psGood: "\x1b[92m", // green — ≥ 30 tok/s
|
|
494
|
+
psSlow: "\x1b[91m", // red — < 10 tok/s
|
|
495
|
+
psMid: "\x1b[97m", // white — in between
|
|
496
|
+
cacheHit: "\x1b[92m", // green — high hit
|
|
497
|
+
cacheWarn: "\x1b[93m", // amber — lowish hit
|
|
498
|
+
reset: "\x1b[39m",
|
|
499
|
+
} as const;
|
|
500
|
+
|
|
501
|
+
const c = (code: string, text: string) => `${code}${text}${STRIP_COLOR.reset}`;
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Glance-style centered stats strip under the input:
|
|
505
|
+
* n Turn · n Steps | wall · tool wall | avg TTFT · n tok/s | cache n%
|
|
506
|
+
*/
|
|
507
|
+
function renderSessionStrip(piContext: unknown): string | null {
|
|
508
|
+
const parts: string[] = [];
|
|
509
|
+
|
|
510
|
+
const turns = tpsTracker.getTurnCount();
|
|
511
|
+
const steps = tpsTracker.getStepCount();
|
|
512
|
+
if (turns > 0 || steps > 0) {
|
|
513
|
+
parts.push(`${c(STRIP_COLOR.count, String(turns))} turn \u00b7 ${c(STRIP_COLOR.count, String(steps))} step${steps === 1 ? "" : "s"}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const llmMs = tpsTracker.getSessionLlmMs();
|
|
517
|
+
const toolMs = tpsTracker.getToolMs();
|
|
518
|
+
// Wall + tool time always rendered together once anything is known —
|
|
519
|
+
// '00:00 · tool 00:00' beats a silently missing slot mid-strip.
|
|
520
|
+
if (turns > 0 || steps > 0) {
|
|
521
|
+
parts.push(`${c(STRIP_COLOR.time, fmtWall(llmMs))} \u00b7 tool ${c(STRIP_COLOR.time, fmtWall(toolMs))}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const ttft = tpsTracker.getAvgTtftMs();
|
|
525
|
+
let avgTps = tpsTracker.getSessionAvgTps();
|
|
526
|
+
const tpsColor = avgTps >= 30 ? STRIP_COLOR.psGood : avgTps < 10 ? STRIP_COLOR.psSlow : STRIP_COLOR.psMid;
|
|
527
|
+
const avgTpsLabel = avgTps >= 100
|
|
528
|
+
? String(Math.round(avgTps))
|
|
529
|
+
: avgTps > 0
|
|
530
|
+
? avgTps.toFixed(1)
|
|
531
|
+
: "0";
|
|
532
|
+
if (ttft !== null || steps > 0) {
|
|
533
|
+
const seg = [
|
|
534
|
+
ttft !== null ? c(STRIP_COLOR.ttft, ttft >= 10000 ? `${Math.round(ttft / 1000)}s` : `${ttft}ms`) + " avg ttft" : null,
|
|
535
|
+
steps > 0 ? c(tpsColor, `${avgTpsLabel} tok/s`) : null,
|
|
536
|
+
].filter(Boolean).join(" \u00b7 ");
|
|
537
|
+
if (seg) parts.push(seg);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const hit = cacheHitPct(piContext);
|
|
541
|
+
if (hit !== null) {
|
|
542
|
+
const hitColor = hit >= 70 ? STRIP_COLOR.cacheHit : STRIP_COLOR.cacheWarn;
|
|
543
|
+
parts.push(c(hitColor, `${hit}% cache hit`));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (parts.length === 0) return null;
|
|
547
|
+
return parts.join(" | ");
|
|
548
|
+
}
|
|
549
|
+
|
|
268
550
|
// ─── TPS streaming-event hooks ──────────────────────────────────────────────
|
|
269
551
|
|
|
270
552
|
/**
|
|
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.
|
|
553
|
+
* Sequential index of the currently-streaming assistant message within the
|
|
554
|
+
* session branch. Tracked locally because pi does not expose a stable message
|
|
555
|
+
* index on streaming events, and the TPS tracker keys records off this index.
|
|
556
|
+
*
|
|
557
|
+
* Both the streaming hooks and the 1s reconciliation scan key records by the
|
|
558
|
+
* same scheme: position among assistant messages in `getBranch()`. To stay in
|
|
559
|
+
* sync, on session_start (and branch changes) we replay the count of persisted
|
|
560
|
+
* assistant messages into this cursor BEFORE any new message_start fires.
|
|
274
561
|
*/
|
|
275
562
|
let tpsStreamingIndex = -1;
|
|
276
563
|
|
|
@@ -279,6 +566,45 @@ function resetTpsStreamingIndex(): void {
|
|
|
279
566
|
tpsStreamingIndex = -1;
|
|
280
567
|
}
|
|
281
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Re-synchronize the streaming hook cursor with the session branch.
|
|
571
|
+
*
|
|
572
|
+
* Root cause of the frozen-TPS bug: this module's cursor starts at -1 for every
|
|
573
|
+
* new session, while the 1s reconciliation scan seeds tracker records at
|
|
574
|
+
* branch-local indexes 0..N-1. Until the cursor caught up past N, every
|
|
575
|
+
* streaming event landed on an already-completed record and was ignored —
|
|
576
|
+
* so live TPS froze at the last completed message for the first N messages of
|
|
577
|
+
* each session (and drifted permanently once cursors diverged).
|
|
578
|
+
*
|
|
579
|
+
* Fix: on session_start, seed the cursor to N-1 (count of persisted assistant
|
|
580
|
+
* messages minus one), so the NEXT message_start maps to branch-local index N,
|
|
581
|
+
* exactly matching what the reconciliation scan will use. Also called when a
|
|
582
|
+
* branch change is observed (compaction/branch switch), since indexes are
|
|
583
|
+
* branch-relative.
|
|
584
|
+
*/
|
|
585
|
+
export function cursorSyncCount(piContext: unknown): void {
|
|
586
|
+
try {
|
|
587
|
+
const ctx = piContext as Record<string, unknown> | undefined;
|
|
588
|
+
const sm = ctx?.sessionManager as { getBranch?: () => unknown[] } | undefined;
|
|
589
|
+
const events = sm?.getBranch?.() ?? [];
|
|
590
|
+
let assistantCount = 0;
|
|
591
|
+
for (const e of events) {
|
|
592
|
+
if (!e || typeof e !== "object") continue;
|
|
593
|
+
const entry = e as Record<string, unknown>;
|
|
594
|
+
if (entry.type !== "message") continue;
|
|
595
|
+
const m = entry.message as Record<string, unknown> | undefined;
|
|
596
|
+
if (!m || m.role !== "assistant") continue;
|
|
597
|
+
const stopReason = m.stopReason as string | undefined;
|
|
598
|
+
if (stopReason === "error" || stopReason === "aborted") continue;
|
|
599
|
+
assistantCount++;
|
|
600
|
+
}
|
|
601
|
+
tpsStreamingIndex = assistantCount - 1;
|
|
602
|
+
} catch {
|
|
603
|
+
// Cursor sync is best-effort; streaming hooks tolerate being behind via
|
|
604
|
+
// the reconciliation scan anyway.
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
282
608
|
/**
|
|
283
609
|
* Subscribe to pi's message streaming events and feed the TPS tracker in real
|
|
284
610
|
* time. This complements the 1s branch-scan in the refresh timer, which only
|
|
@@ -299,14 +625,20 @@ function wireTpsStreamingEvents(pi: ExtensionAPI): void {
|
|
|
299
625
|
if (!m || m.role !== "assistant") return;
|
|
300
626
|
if (m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
301
627
|
tpsStreamingIndex++;
|
|
302
|
-
tpsTracker.
|
|
628
|
+
tpsTracker.onMessageStart(tpsStreamingIndex);
|
|
303
629
|
})) as (event: unknown) => void);
|
|
304
630
|
|
|
305
|
-
pi.on("message_update", ((event: { message: unknown }) => safe(() => {
|
|
631
|
+
pi.on("message_update", ((event: { message: unknown; assistantMessageEvent?: { type?: string; delta?: string } }) => safe(() => {
|
|
306
632
|
if (tpsStreamingIndex < 0) return;
|
|
307
633
|
const m = event.message as Record<string, unknown> | undefined;
|
|
308
634
|
if (!m || m.role !== "assistant") return;
|
|
309
|
-
|
|
635
|
+
// Incremental deltas keep counting O(chunk); the clock starts on the
|
|
636
|
+
// FIRST delta so time-to-first-token is excluded from the rate window.
|
|
637
|
+
const ev = event.assistantMessageEvent;
|
|
638
|
+
const type = ev?.type;
|
|
639
|
+
if ((type === "text_delta" || type === "thinking_delta" || type === "toolcall_delta") && typeof ev?.delta === "string") {
|
|
640
|
+
tpsTracker.onStreamingDelta(tpsStreamingIndex, ev.delta);
|
|
641
|
+
}
|
|
310
642
|
})) as (event: unknown) => void);
|
|
311
643
|
|
|
312
644
|
pi.on("message_end", ((event: { message: unknown }) => safe(() => {
|
|
@@ -314,6 +646,7 @@ function wireTpsStreamingEvents(pi: ExtensionAPI): void {
|
|
|
314
646
|
const m = event.message as Record<string, unknown> | undefined;
|
|
315
647
|
if (!m || m.role !== "assistant") return;
|
|
316
648
|
if (m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
317
|
-
|
|
649
|
+
// Anchor to exact provider usage.output at stream end.
|
|
650
|
+
tpsTracker.onMessageEnd(tpsStreamingIndex, m);
|
|
318
651
|
})) as (event: unknown) => void);
|
|
319
652
|
}
|
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
|