@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/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 || m.role !== "assistant") continue;
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: TPS tracker counts tokens from content,
185
- // not from usage.output (which is 0 during streaming).
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
- // Build layout with proper theme by creating segment contexts
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, _theme) => {
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
- const lines: string[] = [];
254
-
255
- const layout = state.renderer.computeLayout(width);
256
- if (layout.secondaryContent) {
257
- // Hard safety net: never return a line wider than the terminal.
258
- const line = layout.secondaryContent;
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. Tracked
272
- * locally because pi does not expose a stable message index on streaming
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.onMessageUpdate(tpsStreamingIndex, m, false);
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
- tpsTracker.onMessageUpdate(tpsStreamingIndex, m, false);
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
- tpsTracker.onMessageUpdate(tpsStreamingIndex, m, true);
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 — balanced view */
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,
@@ -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",
@@ -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