@d3ara1n/pi-editor-shell 0.9.0 → 0.10.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 CHANGED
@@ -7,7 +7,7 @@ Replaces pi's default editor and status bar with a unified rounded-corner shell
7
7
  ## What shows up where
8
8
 
9
9
  - **Top border** — `  model ·  thinking-level ` (left) + pinned extension statuses (right, via `pinnedStatus` config)
10
- - **Bottom border** — `  ctx NN%/NNk|N.NM · ⚡ cacheRead (total)  hitRate% · NN.N t/s · $N.NNN ` (left) + `  ~/Projects (main +2 ~1 *4) ` (right, shows git branch plus staged, unstaged, and untracked file counts when in a repo). TPS is measured for the latest completed response from its first streamed content delta, excluding time-to-first-token. Session cost includes assistant, tool, compaction, and branch-summary usage; the dollar segment is hidden when the provider reports no priced usage. Session hit rate via `/editor-shell:status`.
10
+ - **Bottom border** — `  ctx NN%/NNk|N.NM · ⚡ cacheRead (total)  hitRate% · NN.N e2e t/s · $N.NNN ` (left) + `  ~/Projects (main +2 ~1 *4) ` (right, shows git branch plus staged, unstaged, and untracked file counts when in a repo; inside a linked worktree the branch carries an `@<name>` tag, e.g. `(feature-x @feature-x +2 ~1)`, so sibling worktrees of one repo are told apart at a glance). Response throughput defaults to client-observed end-to-end visible-text throughput, including local request preparation, network and queue latency, hidden reasoning, and visible generation. It can instead show generation throughput or be hidden; see [Throughput display](#throughput-display). A new turn clears the previous measurement, so unavailable samples never leave stale data in the border. Session cost includes assistant, tool, compaction, and branch-summary usage; the dollar segment is hidden when the provider reports no priced usage. Session hit rate and detailed response timing are available via `/editor-shell:status`.
11
11
  - **Below shell** — Auto-wrapping extension status line (all `setStatus` entries not pinned to the top)
12
12
  - **Border color** follows pi's thinking-level / bash-mode indicator automatically.
13
13
 
@@ -21,6 +21,7 @@ In `~/.pi/agent/settings.json` under the `editorShell` key:
21
21
  {
22
22
  "editorShell": {
23
23
  "pinnedStatus": ["subagent", "access-denied"],
24
+ "tpsDisplay": "end-to-end",
24
25
  "icons": {
25
26
  "model": "robot",
26
27
  "cache": "\\uf0e7"
@@ -54,16 +55,36 @@ How the model is labeled in the top-left border (`"name"` by default):
54
55
 
55
56
  | Value | Example |
56
57
  |-------|---------|
57
- | `"name"` (default) | `Claude Opus 4.8 (Yanproxy)` |
58
- | `"provider-id"` | `yanproxy/anthropic/claude-opus-4-8` |
58
+ | `"name"` (default) | `Claude Opus 4.8` |
59
+ | `"provider-id"` | `anthropic/claude-opus-4-8` |
59
60
 
60
61
  `"name"` uses `model.name`; a model with no name falls back to its id, so the slot never goes blank.
61
62
 
63
+ ### Throughput display
64
+
65
+ Choose the response-throughput metric shown in the bottom border (`"end-to-end"` by default):
66
+
67
+ ```json
68
+ {
69
+ "editorShell": {
70
+ "tpsDisplay": "end-to-end"
71
+ }
72
+ }
73
+ ```
74
+
75
+ | Value | Border label | Measurement |
76
+ |-------|--------------|-------------|
77
+ | `"end-to-end"` (default) | `e2e t/s` | Visible output tokens divided by total turn duration, including wait time and hidden reasoning |
78
+ | `"generation"` | `gen t/s` | Visible tokens after the first divided by the time from first visible text to response completion |
79
+ | `"none"` | — | Hides response throughput from the border |
80
+
81
+ Both throughput values, time to first visible text, total response time, visible-token count, and token source remain available via `/editor-shell:status`. Generation samples below 10 visible tokens or 250 ms are reported as unavailable. Responses containing tool calls cannot provide a portable text-token split. If reasoning is active but the provider does not supply a usable positive reasoning-token count, both throughput values are reported as unavailable rather than treating hidden tokens as visible output.
82
+
62
83
  ## Commands
63
84
 
64
85
  | Command | Description |
65
86
  |---------|-------------|
66
- | `/editor-shell:status` | Show debug info: pinned config, all extension statuses with their keys, cache totals, latest response TPS, and session cost |
87
+ | `/editor-shell:status` | Show debug info: config, extension statuses, cache totals, response wait time, end-to-end and generation throughput, token source, and session cost |
67
88
 
68
89
  ## How it works
69
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-editor-shell",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Replaces pi's default editor and status bar with a unified rounded-corner shell embedding status info in the border",
6
6
  "keywords": [
package/src/config.ts CHANGED
@@ -25,6 +25,9 @@ export interface EditorShellIcons {
25
25
  /** How the model is shown in the top-left border slot. */
26
26
  export type ModelDisplay = "name" | "provider-id";
27
27
 
28
+ /** Which response-throughput metric is shown in the shell border. */
29
+ export type TpsDisplay = "end-to-end" | "generation" | "none";
30
+
28
31
  export interface EditorShellConfig {
29
32
  /**
30
33
  * Status keys to pin to the shell's top-right corner.
@@ -44,6 +47,11 @@ export interface EditorShellConfig {
44
47
  * - `"provider-id"` — `provider/id`.
45
48
  */
46
49
  modelDisplay: ModelDisplay;
50
+ /**
51
+ * Response-throughput metric shown in the bottom border.
52
+ * Detailed response timing remains available via /editor-shell:status.
53
+ */
54
+ tpsDisplay: TpsDisplay;
47
55
  }
48
56
 
49
57
  const ICON_KEYS: ReadonlyArray<keyof EditorShellIcons> = [
@@ -70,6 +78,7 @@ export const DEFAULT_CONFIG: EditorShellConfig = {
70
78
  pinnedStatus: [],
71
79
  icons: {},
72
80
  modelDisplay: "name",
81
+ tpsDisplay: "end-to-end",
73
82
  };
74
83
 
75
84
  /** Read the `editorShell` block from a settings file.
@@ -101,6 +110,13 @@ export function loadEditorShellConfig(cwd?: string): EditorShellConfig {
101
110
  modelDisplayRaw === "name" || modelDisplayRaw === "provider-id"
102
111
  ? modelDisplayRaw
103
112
  : DEFAULT_CONFIG.modelDisplay;
113
+ const tpsDisplayRaw = raw.tpsDisplay;
114
+ const tpsDisplay =
115
+ tpsDisplayRaw === "end-to-end" ||
116
+ tpsDisplayRaw === "generation" ||
117
+ tpsDisplayRaw === "none"
118
+ ? tpsDisplayRaw
119
+ : DEFAULT_CONFIG.tpsDisplay;
104
120
  return {
105
121
  pinnedStatus: Array.isArray(pinned)
106
122
  ? pinned.filter((k): k is string => typeof k === "string")
@@ -110,5 +126,6 @@ export function loadEditorShellConfig(cwd?: string): EditorShellConfig {
110
126
  ? filterIcons(iconsRaw as Record<string, unknown>)
111
127
  : {},
112
128
  modelDisplay,
129
+ tpsDisplay,
113
130
  };
114
131
  }
package/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { spawn } from "node:child_process";
4
+ import { readFileSync, statSync } from "node:fs";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
6
7
  import { CardEditor, type FrameProvider, type SpinnerPhase } from "./card-editor.ts";
7
8
  import { DEFAULT_CONFIG, loadEditorShellConfig, type EditorShellConfig, type EditorShellIcons } from "./config.ts";
9
+ import { calculateResponsePerformance, type ResponsePerformance } from "./tps.ts";
8
10
 
9
11
  /**
10
12
  * pi-editor-shell — Replaces pi's default editor and status bar with a
@@ -75,6 +77,7 @@ const DEFAULT_ICONS: EditorShellIcons = {
75
77
  interface UsageSnap {
76
78
  input?: number;
77
79
  output?: number;
80
+ reasoning?: number;
78
81
  cacheRead?: number;
79
82
  cacheWrite?: number;
80
83
  cost?: {
@@ -83,7 +86,9 @@ interface UsageSnap {
83
86
  }
84
87
  interface MsgSnap {
85
88
  role: string;
89
+ content?: Array<{ type: string; text?: string }>;
86
90
  usage?: UsageSnap;
91
+ stopReason?: string;
87
92
  }
88
93
  interface EntrySnap {
89
94
  type: string;
@@ -185,6 +190,24 @@ function formatTokens(n: number): string {
185
190
  return String(n);
186
191
  }
187
192
 
193
+ function formatDuration(ms: number | undefined): string {
194
+ if (ms == null || !Number.isFinite(ms) || ms < 0) return "n/a";
195
+ if (ms < 1_000) return `${ms.toFixed(0)} ms`;
196
+ const seconds = ms / 1_000;
197
+ if (seconds < 60) return `${trimFixed1(seconds)}s`;
198
+ const minutes = Math.floor(seconds / 60);
199
+ const remainingSeconds = seconds - minutes * 60;
200
+ if (minutes < 60) return `${minutes}m ${trimFixed1(remainingSeconds)}s`;
201
+ const hours = Math.floor(minutes / 60);
202
+ return `${hours}h ${minutes % 60}m`;
203
+ }
204
+
205
+ function formatTps(tps: number): string {
206
+ if (tps < 0.1) return tps.toFixed(3);
207
+ if (tps < 1) return tps.toFixed(2);
208
+ return tps.toFixed(1);
209
+ }
210
+
188
211
  // ── Git dirty state (event-driven, not TTL) ───────────────────────
189
212
  // Refreshed at session_start and after every agent turn (turn_end).
190
213
  interface GitDirty {
@@ -248,6 +271,60 @@ function refreshGitDirty(cwd: string, onDone?: () => void): void {
248
271
  child.on("close", (code) => settle(code === 0, stdout));
249
272
  }
250
273
 
274
+ /** Filesystem boundary for {@link linkedWorktreeName}, injectable so tests
275
+ * can exercise the walk-up and pointer parsing without touching the real
276
+ * filesystem. `isFile` distinguishes "missing" (undefined) from "exists but
277
+ * not a regular file" (false) — the walk-up continues past the former and
278
+ * stops at the latter (a `.git` directory = main worktree). */
279
+ export interface WorktreeIO {
280
+ isFile(p: string): boolean | undefined;
281
+ readText(p: string): string;
282
+ }
283
+
284
+ const NODE_IO: WorktreeIO = {
285
+ isFile(p) {
286
+ try {
287
+ return statSync(p).isFile();
288
+ } catch (e) {
289
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined;
290
+ throw e;
291
+ }
292
+ },
293
+ readText(p) {
294
+ return readFileSync(p, "utf8");
295
+ },
296
+ };
297
+
298
+ /** Name of the linked worktree containing `cwd`, or null when `cwd` sits in
299
+ * the main worktree or outside any repo. A linked worktree marks itself with
300
+ * a `.git` *file* pointing at `<main>/.git/worktrees/<name>`; the parent-dir
301
+ * check keeps submodule pointers (`.git/modules/…`) from masquerading as
302
+ * worktrees.
303
+ * @internal — exported for testing; the session reads it once at start. */
304
+ export function linkedWorktreeName(cwd: string, io: WorktreeIO = NODE_IO): string | null {
305
+ let dir = path.resolve(cwd);
306
+ for (;;) {
307
+ const dotGit = path.join(dir, ".git");
308
+ const kind = io.isFile(dotGit);
309
+ if (kind === undefined) {
310
+ const parent = path.dirname(dir);
311
+ if (parent === dir) return null;
312
+ dir = parent;
313
+ continue;
314
+ }
315
+ if (!kind) return null;
316
+ try {
317
+ const content = io.readText(dotGit).trim();
318
+ if (!content.startsWith("gitdir: ")) return null;
319
+ const gitdir = path.resolve(dir, content.slice("gitdir: ".length));
320
+ if (path.basename(path.dirname(gitdir)) !== "worktrees") return null;
321
+ return path.basename(gitdir) || null;
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+ }
327
+
251
328
  /** Format dirty state as "+staged ~unstaged *untracked" (leading space), or ""
252
329
  * if clean / unknown — ready to splice into a "(branch…)" segment. */
253
330
  function gitDirtyDisplay(): string {
@@ -279,17 +356,24 @@ export default function (pi: ExtensionAPI) {
279
356
  let footerSnap: FooterSnap | undefined;
280
357
  // CWD cached from session_start — used by turn_end to refresh git dirty.
281
358
  let _cwd = "";
359
+ // Linked-worktree name for the session cwd, resolved once at session_start
360
+ // (worktree names are stable for the lifetime of a session). Null = main
361
+ // worktree or outside any repo — no badge.
362
+ let _worktreeName: string | null = null;
282
363
  // cacheRead total + latest-turn usage, refreshed at session_start +
283
364
  // agent_end. The render provider reads these instead of re-scanning
284
365
  // entries every frame.
285
366
  let _cacheTotal = 0;
286
367
  let _latestUsage: UsageSnap | undefined;
287
- // Latest completed response throughput. Timing begins with the first streamed
288
- // content delta, excluding time-to-first-token; it is intentionally not
289
- // reconstructed for resumed sessions because persisted messages have no end time.
290
- let _streamStartedAt: number | undefined;
291
- let _pendingResponseElapsedMs: number | undefined;
292
- let _latestTps: number | undefined;
368
+ // Client-observed timing for the current assistant response. The completed
369
+ // measurement replaces the previous turn even when throughput is unavailable,
370
+ // so the shell never displays stale performance data.
371
+ let _turnStartedAt: number | undefined;
372
+ let _firstVisibleTextAt: number | undefined;
373
+ let _responseEndedAt: number | undefined;
374
+ let _reasoningExpected = false;
375
+ let _sawThinking = false;
376
+ let _latestPerformance: ResponsePerformance | undefined;
293
377
  // Full-session billable cost, including tool/summarization usage. Zero means the
294
378
  // provider supplied no priced usage, so the border omits the dollar segment.
295
379
  let _sessionCost = 0;
@@ -297,20 +381,30 @@ export default function (pi: ExtensionAPI) {
297
381
  // ── Phase-aware spinner + lifecycle ────────────────────────────
298
382
  // Each event asks the editor for a phase; CardEditor.setSpinner is itself
299
383
  // a same-phase no-op, so rapid event streams never reset the animation.
384
+ pi.on("turn_start", (_event, ctx) => {
385
+ _turnStartedAt = performance.now();
386
+ _firstVisibleTextAt = undefined;
387
+ _responseEndedAt = undefined;
388
+ _reasoningExpected = Boolean(ctx.model?.reasoning && pi.getThinkingLevel() !== "off");
389
+ _sawThinking = false;
390
+ _latestPerformance = undefined;
391
+ editor?.setSpinner("thinking");
392
+ editor?.requestRender();
393
+ });
300
394
  pi.on("message_start", (event) => {
301
395
  if (event.message.role !== "assistant") return;
302
- _streamStartedAt = undefined;
303
- _pendingResponseElapsedMs = undefined;
396
+ _firstVisibleTextAt = undefined;
397
+ _responseEndedAt = undefined;
398
+ _sawThinking = false;
304
399
  });
305
- pi.on("turn_start", () => editor?.setSpinner("thinking"));
306
400
  pi.on("message_update", (event) => {
307
- const t = event.assistantMessageEvent.type;
308
- if (
309
- _streamStartedAt == null &&
310
- (t === "thinking_delta" || t === "text_delta" || t === "toolcall_delta")
311
- ) {
312
- _streamStartedAt = Date.now();
401
+ const update = event.assistantMessageEvent;
402
+ const t = update.type;
403
+ if (t === "text_delta" && update.delta.length > 0) {
404
+ _firstVisibleTextAt ??= performance.now();
313
405
  }
406
+ if (t.startsWith("thinking_")) _sawThinking = true;
407
+
314
408
  let next: SpinnerPhase;
315
409
  if (t.startsWith("thinking_")) next = "thinking";
316
410
  else if (t.startsWith("text_")) next = "outputting";
@@ -320,9 +414,13 @@ export default function (pi: ExtensionAPI) {
320
414
  });
321
415
  pi.on("message_end", (event) => {
322
416
  if (event.message.role !== "assistant") return;
323
- const elapsedMs = _streamStartedAt == null ? 0 : Date.now() - _streamStartedAt;
324
- _pendingResponseElapsedMs = elapsedMs > 0 ? elapsedMs : undefined;
325
- _streamStartedAt = undefined;
417
+ _responseEndedAt = performance.now();
418
+ const message = event.message as MsgSnap;
419
+ const hasVisibleText = message.content?.some(
420
+ (part) => part.type === "text" && typeof part.text === "string" && part.text.length > 0,
421
+ ) ?? false;
422
+ // Non-streaming providers expose visible text only when the response completes.
423
+ if (hasVisibleText) _firstVisibleTextAt ??= _responseEndedAt;
326
424
  });
327
425
  pi.on("tool_execution_start", () => editor?.setSpinner("exec"));
328
426
  pi.on("agent_end", (_event, ctx) => {
@@ -334,9 +432,12 @@ export default function (pi: ExtensionAPI) {
334
432
  editor?.setSpinner(null);
335
433
  });
336
434
  pi.on("session_shutdown", () => {
337
- _streamStartedAt = undefined;
338
- _pendingResponseElapsedMs = undefined;
339
- _latestTps = undefined;
435
+ _turnStartedAt = undefined;
436
+ _firstVisibleTextAt = undefined;
437
+ _responseEndedAt = undefined;
438
+ _reasoningExpected = false;
439
+ _sawThinking = false;
440
+ _latestPerformance = undefined;
340
441
  _sessionCost = 0;
341
442
  editor?.setSpinner(null);
342
443
  editor = undefined;
@@ -354,10 +455,26 @@ export default function (pi: ExtensionAPI) {
354
455
  // Async — never blocks the event loop; re-renders once settled.
355
456
  pi.on("turn_end", (event) => {
356
457
  if (event.message.role === "assistant") {
357
- const output = (event.message as MsgSnap).usage?.output ?? 0;
358
- const elapsedMs = _pendingResponseElapsedMs ?? 0;
359
- _latestTps = elapsedMs > 0 && output > 0 ? output / (elapsedMs / 1000) : undefined;
360
- _pendingResponseElapsedMs = undefined;
458
+ const message = event.message as MsgSnap;
459
+ const hasVisibleText = message.content?.some(
460
+ (part) => part.type === "text" && typeof part.text === "string" && part.text.length > 0,
461
+ ) ?? false;
462
+ _latestPerformance = calculateResponsePerformance({
463
+ outputTokens: message.usage?.output,
464
+ reasoningTokens: message.usage?.reasoning,
465
+ reasoningExpected: _reasoningExpected || _sawThinking,
466
+ turnStartedAt: _turnStartedAt,
467
+ firstVisibleTextAt: _firstVisibleTextAt,
468
+ responseEndedAt: _responseEndedAt,
469
+ hasVisibleText,
470
+ hasToolCall: message.content?.some((part) => part.type === "toolCall") ?? false,
471
+ stopReason: message.stopReason,
472
+ });
473
+ _turnStartedAt = undefined;
474
+ _firstVisibleTextAt = undefined;
475
+ _responseEndedAt = undefined;
476
+ _reasoningExpected = false;
477
+ _sawThinking = false;
361
478
  editor?.requestRender();
362
479
  }
363
480
  if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
@@ -367,13 +484,17 @@ export default function (pi: ExtensionAPI) {
367
484
  if (!ctx.hasUI) return;
368
485
 
369
486
  _cwd = ctx.cwd;
487
+ _worktreeName = linkedWorktreeName(ctx.cwd);
370
488
  config = loadEditorShellConfig(ctx.cwd);
371
489
  icons = { ...DEFAULT_ICONS, ...config.icons };
372
490
  _cacheTotal = sumCacheRead(ctx);
373
491
  _latestUsage = latestAssistantUsage(ctx);
374
- _streamStartedAt = undefined;
375
- _pendingResponseElapsedMs = undefined;
376
- _latestTps = undefined;
492
+ _turnStartedAt = undefined;
493
+ _firstVisibleTextAt = undefined;
494
+ _responseEndedAt = undefined;
495
+ _reasoningExpected = false;
496
+ _sawThinking = false;
497
+ _latestPerformance = undefined;
377
498
  _sessionCost = sumSessionCost(ctx);
378
499
  refreshGitDirty(ctx.cwd, () => editor?.requestRender());
379
500
 
@@ -422,22 +543,30 @@ export default function (pi: ExtensionAPI) {
422
543
  _cacheTotal > 0
423
544
  ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${icons.cache} ${formatTokens(cacheReadNow)} (${formatTokens(_cacheTotal)})${hitRate != null ? ` ${icons.hitRate} ${hitRate.toFixed(1)}%` : ""}`)}`
424
545
  : "";
425
- const tpsPart =
426
- _latestTps != null
427
- ? `${theme.fg("dim", " · ")}${theme.fg("success", `${_latestTps.toFixed(1)} t/s`)}`
428
- : "";
546
+ const displayedTps = config.tpsDisplay === "end-to-end"
547
+ ? _latestPerformance?.e2eTps
548
+ : config.tpsDisplay === "generation"
549
+ ? _latestPerformance?.generationTps
550
+ : undefined;
551
+ const tpsLabel = config.tpsDisplay === "end-to-end" ? "e2e" : "gen";
552
+ const tpsPart = displayedTps != null
553
+ ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${formatTps(displayedTps)} ${tpsLabel} t/s`)}`
554
+ : "";
429
555
  const costPart =
430
556
  _sessionCost > 0
431
557
  ? `${theme.fg("dim", " · ")}${theme.fg("warning", `$${_sessionCost.toFixed(3)}`)}`
432
558
  : "";
433
559
 
434
- // Git branch + dirty state — pi's format: ~/Projects (main).
560
+ // Git branch + worktree badge + dirty state — pi's format:
561
+ // ~/Projects (main). Inside a linked worktree the branch carries an
562
+ // @<name> tag, so sibling worktrees of one repo are told apart at a glance.
435
563
  const cwdText = formatCwd(ctx.cwd);
436
564
  const branch = footerSnap?.getGitBranch() ?? null;
565
+ const worktreeTag = _worktreeName ? ` @${_worktreeName}` : "";
437
566
  const dirty = branch ? gitDirtyDisplay() : "";
438
567
  const cwdDisplay =
439
568
  branch && branch !== "detached"
440
- ? `${icons.folder} ${cwdText} (${branch}${dirty})`
569
+ ? `${icons.folder} ${cwdText} (${branch}${worktreeTag}${dirty})`
441
570
  : `${icons.folder} ${cwdText}`;
442
571
 
443
572
  // Model in accent; thinking label in its level token — same hue the
@@ -511,6 +640,7 @@ export default function (pi: ExtensionAPI) {
511
640
  lines.push("[editor-shell config]");
512
641
  lines.push(` pinnedStatus: [${config.pinnedStatus.join(", ")}]`);
513
642
  lines.push(` modelDisplay: ${config.modelDisplay}`);
643
+ lines.push(` tpsDisplay: ${config.tpsDisplay}`);
514
644
 
515
645
  lines.push("");
516
646
  lines.push("[extension statuses]");
@@ -544,13 +674,33 @@ export default function (pi: ExtensionAPI) {
544
674
  const sessionCost = sumSessionCost(ctx);
545
675
  _sessionCost = sessionCost;
546
676
  lines.push(` session cost: ${sessionCost > 0 ? `$${sessionCost.toFixed(3)}` : "n/a"}`);
547
- lines.push(` latest response TPS: ${_latestTps != null ? `${_latestTps.toFixed(1)} t/s` : "n/a"}`);
548
677
  const latest = latestAssistantUsage(ctx);
549
678
  const now = latest?.cacheRead ?? 0;
550
679
  lines.push(` this turn cacheRead: ${formatTokens(now)}`);
551
680
  const hr = cacheHitRate(latest);
552
681
  lines.push(` this turn hit rate: ${hr != null ? `${hr.toFixed(1)}%` : "n/a"}`);
553
682
 
683
+ lines.push("");
684
+ lines.push("[response performance]");
685
+ const perf = _latestPerformance;
686
+ const throughputReason = perf?.throughputUnavailableReason;
687
+ const generationReason = throughputReason ?? perf?.generationUnavailableReason;
688
+ lines.push(` wait to first visible text: ${formatDuration(perf?.waitMs)}`);
689
+ lines.push(` total response time: ${formatDuration(perf?.totalMs)}`);
690
+ lines.push(
691
+ ` end-to-end throughput: ${perf?.e2eTps != null ? `${formatTps(perf.e2eTps)} t/s` : `n/a${throughputReason ? ` (${throughputReason})` : ""}`}`,
692
+ );
693
+ lines.push(
694
+ ` generation throughput: ${perf?.generationTps != null ? `${formatTps(perf.generationTps)} t/s` : `n/a${generationReason ? ` (${generationReason})` : ""}`}`,
695
+ );
696
+ lines.push(` visible output tokens: ${perf?.visibleTokens ?? "n/a"}`);
697
+ const tokenSource = perf?.tokenSource === "provider-output-minus-reasoning"
698
+ ? "provider output minus reasoning"
699
+ : perf?.tokenSource === "provider-output"
700
+ ? "provider output (reasoning not expected)"
701
+ : "n/a";
702
+ lines.push(` token source: ${tokenSource}`);
703
+
554
704
  lines.push("");
555
705
  lines.push(`[context] cwd: ${ctx.cwd}`);
556
706
  const branch = footerSnap?.getGitBranch();
@@ -558,6 +708,7 @@ export default function (pi: ExtensionAPI) {
558
708
  if (branch) {
559
709
  const dirty = gitDirtyDisplay().trim();
560
710
  lines.push(` git dirty: ${dirty || "clean"}`);
711
+ lines.push(` worktree: ${_worktreeName ?? "main"}`);
561
712
  }
562
713
  const m = ctx.model;
563
714
  lines.push(` model: ${m ? `${m.provider}/${m.id}:${pi.getThinkingLevel()}` : "none"}`);
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ calculateResponsePerformance,
5
+ type ResponsePerformanceSample,
6
+ } from "./tps.ts";
7
+
8
+ function sample(overrides: Partial<ResponsePerformanceSample> = {}): ResponsePerformanceSample {
9
+ return {
10
+ outputTokens: 101,
11
+ reasoningTokens: 20,
12
+ reasoningExpected: true,
13
+ turnStartedAt: 1_000,
14
+ firstVisibleTextAt: 3_000,
15
+ responseEndedAt: 7_000,
16
+ hasVisibleText: true,
17
+ hasToolCall: false,
18
+ stopReason: "stop",
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ test("calculates wait, end-to-end throughput, and generation throughput", () => {
24
+ assert.deepEqual(calculateResponsePerformance(sample()), {
25
+ waitMs: 2_000,
26
+ totalMs: 6_000,
27
+ generationMs: 4_000,
28
+ visibleTokens: 81,
29
+ e2eTps: 13.5,
30
+ generationTps: 20,
31
+ tokenSource: "provider-output-minus-reasoning",
32
+ });
33
+ });
34
+
35
+ test("uses provider output when reasoning is not expected", () => {
36
+ const result = calculateResponsePerformance(sample({
37
+ outputTokens: 51,
38
+ reasoningTokens: undefined,
39
+ reasoningExpected: false,
40
+ firstVisibleTextAt: 2_000,
41
+ responseEndedAt: 6_000,
42
+ }));
43
+ assert.equal(result.visibleTokens, 51);
44
+ assert.equal(result.e2eTps, 10.2);
45
+ assert.equal(result.generationTps, 12.5);
46
+ assert.equal(result.tokenSource, "provider-output");
47
+ });
48
+
49
+ test("rejects throughput when expected reasoning usage is missing or ambiguous", () => {
50
+ for (const reasoningTokens of [undefined, 0]) {
51
+ const result = calculateResponsePerformance(sample({ reasoningTokens }));
52
+ assert.equal(result.waitMs, 2_000);
53
+ assert.equal(result.totalMs, 6_000);
54
+ assert.equal(result.e2eTps, undefined);
55
+ assert.equal(result.generationTps, undefined);
56
+ assert.equal(
57
+ result.throughputUnavailableReason,
58
+ "provider omitted usable reasoning-token usage",
59
+ );
60
+ }
61
+ });
62
+
63
+ test("rejects throughput for responses containing tool calls", () => {
64
+ const result = calculateResponsePerformance(sample({ hasToolCall: true }));
65
+ assert.equal(result.waitMs, 2_000);
66
+ assert.equal(result.e2eTps, undefined);
67
+ assert.equal(result.throughputUnavailableReason, "text and tool-call tokens are not separable");
68
+ });
69
+
70
+ test("rejects throughput for failed and aborted responses", () => {
71
+ for (const stopReason of ["error", "aborted"]) {
72
+ const result = calculateResponsePerformance(sample({ stopReason }));
73
+ assert.equal(result.e2eTps, undefined);
74
+ assert.equal(result.throughputUnavailableReason, "response failed or was aborted");
75
+ }
76
+ });
77
+
78
+ test("rejects throughput when there is no visible text", () => {
79
+ const result = calculateResponsePerformance(sample({ hasVisibleText: false }));
80
+ assert.equal(result.e2eTps, undefined);
81
+ assert.equal(result.throughputUnavailableReason, "response contained no visible text");
82
+ });
83
+
84
+ test("keeps end-to-end throughput when the generation sample is too small", () => {
85
+ const result = calculateResponsePerformance(sample({
86
+ outputTokens: 9,
87
+ reasoningTokens: 0,
88
+ firstVisibleTextAt: 6_800,
89
+ }));
90
+ assert.equal(result.e2eTps, 1.5);
91
+ assert.equal(result.generationTps, undefined);
92
+ assert.equal(result.generationUnavailableReason, "fewer than 10 visible tokens");
93
+ });
94
+
95
+ test("rejects a generation sample shorter than 250 ms", () => {
96
+ const result = calculateResponsePerformance(sample({
97
+ outputTokens: 10,
98
+ reasoningTokens: 0,
99
+ firstVisibleTextAt: 6_751,
100
+ }));
101
+ assert.equal(result.e2eTps, 10 / 6);
102
+ assert.equal(result.generationTps, undefined);
103
+ assert.equal(result.generationUnavailableReason, "visible generation lasted less than 250 ms");
104
+ });
105
+
106
+ test("represents non-streaming output as full wait with unavailable generation throughput", () => {
107
+ const result = calculateResponsePerformance(sample({
108
+ outputTokens: 50,
109
+ reasoningTokens: 0,
110
+ firstVisibleTextAt: 7_000,
111
+ }));
112
+ assert.equal(result.waitMs, 6_000);
113
+ assert.equal(result.totalMs, 6_000);
114
+ assert.equal(result.e2eTps, 50 / 6);
115
+ assert.equal(result.generationTps, undefined);
116
+ assert.equal(result.generationUnavailableReason, "visible generation lasted less than 250 ms");
117
+ });
118
+
119
+ test("rejects invalid provider usage", () => {
120
+ assert.equal(
121
+ calculateResponsePerformance(sample({ outputTokens: undefined })).throughputUnavailableReason,
122
+ "provider output-token usage is unavailable",
123
+ );
124
+ assert.equal(
125
+ calculateResponsePerformance(sample({ reasoningTokens: 102 })).throughputUnavailableReason,
126
+ "provider reasoning-token usage is invalid",
127
+ );
128
+ });
129
+
130
+ test("rejects invalid timing without manufacturing rates", () => {
131
+ const result = calculateResponsePerformance(sample({ responseEndedAt: 500 }));
132
+ assert.equal(result.waitMs, undefined);
133
+ assert.equal(result.totalMs, undefined);
134
+ assert.equal(result.e2eTps, undefined);
135
+ assert.equal(result.throughputUnavailableReason, "end-to-end timing data is unavailable");
136
+ });
package/src/tps.ts ADDED
@@ -0,0 +1,128 @@
1
+ const MIN_GENERATION_TOKENS = 10;
2
+ const MIN_GENERATION_DURATION_MS = 250;
3
+
4
+ export type TpsTokenSource = "provider-output" | "provider-output-minus-reasoning";
5
+
6
+ /** @internal — exported for focused throughput tests. */
7
+ export interface ResponsePerformanceSample {
8
+ outputTokens?: number;
9
+ reasoningTokens?: number;
10
+ reasoningExpected: boolean;
11
+ turnStartedAt?: number;
12
+ firstVisibleTextAt?: number;
13
+ responseEndedAt?: number;
14
+ hasVisibleText: boolean;
15
+ hasToolCall: boolean;
16
+ stopReason?: string;
17
+ }
18
+
19
+ /** @internal — exported for focused throughput tests. */
20
+ export interface ResponsePerformance {
21
+ waitMs?: number;
22
+ totalMs?: number;
23
+ generationMs?: number;
24
+ visibleTokens?: number;
25
+ e2eTps?: number;
26
+ generationTps?: number;
27
+ tokenSource?: TpsTokenSource;
28
+ throughputUnavailableReason?: string;
29
+ generationUnavailableReason?: string;
30
+ }
31
+
32
+ function validTimestamp(value: number | undefined): value is number {
33
+ return value != null && Number.isFinite(value);
34
+ }
35
+
36
+ /**
37
+ * Calculate client-observed response performance for one completed assistant message.
38
+ *
39
+ * End-to-end throughput includes all time after turn start: local request preparation,
40
+ * network and queue latency, hidden reasoning, and visible generation. Generation
41
+ * throughput follows the common TPOT boundary: response completion minus time to the
42
+ * first visible text, divided across the remaining visible tokens.
43
+ *
44
+ * @internal — exported for focused throughput tests.
45
+ */
46
+ export function calculateResponsePerformance(
47
+ sample: ResponsePerformanceSample,
48
+ ): ResponsePerformance {
49
+ const result: ResponsePerformance = {};
50
+ const start = sample.turnStartedAt;
51
+ const first = sample.firstVisibleTextAt;
52
+ const end = sample.responseEndedAt;
53
+
54
+ if (validTimestamp(start) && validTimestamp(end) && end >= start) {
55
+ result.totalMs = end - start;
56
+ }
57
+ if (
58
+ validTimestamp(start) &&
59
+ validTimestamp(first) &&
60
+ validTimestamp(end) &&
61
+ first >= start &&
62
+ first <= end
63
+ ) {
64
+ result.waitMs = first - start;
65
+ result.generationMs = end - first;
66
+ }
67
+
68
+ if (sample.stopReason === "error" || sample.stopReason === "aborted") {
69
+ result.throughputUnavailableReason = "response failed or was aborted";
70
+ return result;
71
+ }
72
+ if (sample.hasToolCall) {
73
+ result.throughputUnavailableReason = "text and tool-call tokens are not separable";
74
+ return result;
75
+ }
76
+ if (!sample.hasVisibleText) {
77
+ result.throughputUnavailableReason = "response contained no visible text";
78
+ return result;
79
+ }
80
+
81
+ const output = sample.outputTokens;
82
+ const reasoning = sample.reasoningTokens;
83
+ if (output == null || !Number.isFinite(output) || output < 0) {
84
+ result.throughputUnavailableReason = "provider output-token usage is unavailable";
85
+ return result;
86
+ }
87
+ // Some adapters normalize an omitted reasoning count to zero. When reasoning
88
+ // was enabled or observed, zero is therefore ambiguous and cannot safely be
89
+ // used to derive a visible-token count.
90
+ if (sample.reasoningExpected && (reasoning == null || reasoning === 0)) {
91
+ result.throughputUnavailableReason = "provider omitted usable reasoning-token usage";
92
+ return result;
93
+ }
94
+ if (reasoning != null && (!Number.isFinite(reasoning) || reasoning < 0 || reasoning > output)) {
95
+ result.throughputUnavailableReason = "provider reasoning-token usage is invalid";
96
+ return result;
97
+ }
98
+
99
+ const visibleTokens = output - (reasoning ?? 0);
100
+ if (visibleTokens <= 0) {
101
+ result.throughputUnavailableReason = "provider reported no visible output tokens";
102
+ return result;
103
+ }
104
+
105
+ result.visibleTokens = visibleTokens;
106
+ result.tokenSource = reasoning == null
107
+ ? "provider-output"
108
+ : "provider-output-minus-reasoning";
109
+
110
+ if (result.totalMs == null || result.totalMs <= 0) {
111
+ result.throughputUnavailableReason = "end-to-end timing data is unavailable";
112
+ return result;
113
+ }
114
+ result.e2eTps = visibleTokens / (result.totalMs / 1000);
115
+
116
+ if (result.generationMs == null) {
117
+ result.generationUnavailableReason = "first visible-text timing is unavailable";
118
+ } else if (visibleTokens < MIN_GENERATION_TOKENS) {
119
+ result.generationUnavailableReason = `fewer than ${MIN_GENERATION_TOKENS} visible tokens`;
120
+ } else if (result.generationMs < MIN_GENERATION_DURATION_MS) {
121
+ result.generationUnavailableReason = `visible generation lasted less than ${MIN_GENERATION_DURATION_MS} ms`;
122
+ } else {
123
+ // N visible tokens span N - 1 post-first-token intervals.
124
+ result.generationTps = (visibleTokens - 1) / (result.generationMs / 1000);
125
+ }
126
+
127
+ return result;
128
+ }
@@ -0,0 +1,63 @@
1
+ import * as assert from "node:assert/strict";
2
+ import * as path from "node:path";
3
+ import { describe, it } from "node:test";
4
+
5
+ import { linkedWorktreeName, type WorktreeIO } from "./index.ts";
6
+
7
+ /** In-memory filesystem fake: path → file content, or `"dir"` for a directory.\n * Keys and lookups go through path.resolve, so tests use plain absolute\n * paths and no real file is ever touched. */
8
+ function fakeIO(entries: Record<string, string>): WorktreeIO {
9
+ const map = new Map(Object.entries(entries).map(([k, v]) => [path.resolve(k), v]));
10
+ return {
11
+ isFile(p) {
12
+ const v = map.get(path.resolve(p));
13
+ return v === undefined ? undefined : v !== "dir";
14
+ },
15
+ readText(p) {
16
+ const v = map.get(path.resolve(p));
17
+ if (v === undefined || v === "dir") throw new Error(`ENOENT: ${p}`);
18
+ return v;
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("linkedWorktreeName", () => {
24
+ it("returns null in the main worktree (.git is a directory)", () => {
25
+ const io = fakeIO({ "/r/.git": "dir" });
26
+ assert.equal(linkedWorktreeName("/r", io), null);
27
+ assert.equal(linkedWorktreeName("/r/src", io), null);
28
+ });
29
+
30
+ it("returns the worktree name for a linked worktree", () => {
31
+ const io = fakeIO({
32
+ "/r/.git": "dir",
33
+ "/wt/.git": "gitdir: /r/.git/worktrees/feature-x\n",
34
+ });
35
+ assert.equal(linkedWorktreeName("/wt", io), "feature-x");
36
+ assert.equal(linkedWorktreeName("/wt/src/nested", io), "feature-x");
37
+ });
38
+
39
+ it("resolves a relative gitdir pointer", () => {
40
+ const io = fakeIO({
41
+ "/r/.git": "dir",
42
+ "/wt/.git": "gitdir: ../r/.git/worktrees/rel-wt",
43
+ });
44
+ assert.equal(linkedWorktreeName("/wt", io), "rel-wt");
45
+ });
46
+
47
+ it("returns null for a submodule-style pointer", () => {
48
+ const io = fakeIO({
49
+ "/super/.git": "dir",
50
+ "/super/lib/.git": "gitdir: /super/.git/modules/lib",
51
+ });
52
+ assert.equal(linkedWorktreeName("/super/lib", io), null);
53
+ });
54
+
55
+ it("returns null for a non-pointer .git file", () => {
56
+ const io = fakeIO({ "/wt/.git": "garbage" });
57
+ assert.equal(linkedWorktreeName("/wt", io), null);
58
+ });
59
+
60
+ it("returns null outside any repo", () => {
61
+ assert.equal(linkedWorktreeName("/nowhere", fakeIO({})), null);
62
+ });
63
+ });