@d3ara1n/pi-editor-shell 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 client-observed visible-text throughput for the latest reliable completed response: `(visible output tokens - 1) / (last text delta - first text delta)`, excluding time-to-first-token and provider-reported reasoning tokens. Responses containing tool calls, failed/aborted responses, samples below 10 visible tokens, and samples shorter than 250 ms do not replace the last valid TPS. Providers without a reasoning-token breakdown may still include hidden reasoning in the count. 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"
@@ -59,11 +60,31 @@ How the model is labeled in the top-left border (`"name"` by default):
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 reliable visible-text 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.1",
3
+ "version": "0.10.1",
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
  }
@@ -0,0 +1,47 @@
1
+ import * as assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import { parseGitPorcelain } from "./index.ts";
5
+
6
+ describe("parseGitPorcelain", () => {
7
+ it("preserves the leading space of the first unstaged entry", () => {
8
+ assert.deepEqual(parseGitPorcelain(" M example.ts\n"), {
9
+ staged: 0,
10
+ unstaged: 1,
11
+ untracked: 0,
12
+ });
13
+ });
14
+
15
+ it("counts multiple unstaged entries without inventing a staged entry", () => {
16
+ assert.deepEqual(parseGitPorcelain(" M first.ts\n D second.ts\n"), {
17
+ staged: 0,
18
+ unstaged: 2,
19
+ untracked: 0,
20
+ });
21
+ });
22
+
23
+ it("counts staged, unstaged, and untracked entries independently", () => {
24
+ const output = " M first.ts\nA added.ts\nMM both.ts\n?? new-directory/\n!! ignored.ts\n";
25
+ assert.deepEqual(parseGitPorcelain(output), {
26
+ staged: 2,
27
+ unstaged: 2,
28
+ untracked: 1,
29
+ });
30
+ });
31
+
32
+ it("accepts output without a trailing newline", () => {
33
+ assert.deepEqual(parseGitPorcelain(" M example.ts"), {
34
+ staged: 0,
35
+ unstaged: 1,
36
+ untracked: 0,
37
+ });
38
+ });
39
+
40
+ it("returns zero counts for empty output", () => {
41
+ assert.deepEqual(parseGitPorcelain(""), {
42
+ staged: 0,
43
+ unstaged: 0,
44
+ untracked: 0,
45
+ });
46
+ });
47
+ });
package/src/index.ts CHANGED
@@ -1,11 +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";
8
- import { calculateVisibleTextTps } from "./tps.ts";
9
+ import { calculateResponsePerformance, type ResponsePerformance } from "./tps.ts";
9
10
 
10
11
  /**
11
12
  * pi-editor-shell — Replaces pi's default editor and status bar with a
@@ -85,7 +86,7 @@ interface UsageSnap {
85
86
  }
86
87
  interface MsgSnap {
87
88
  role: string;
88
- content?: Array<{ type: string }>;
89
+ content?: Array<{ type: string; text?: string }>;
89
90
  usage?: UsageSnap;
90
91
  stopReason?: string;
91
92
  }
@@ -189,6 +190,24 @@ function formatTokens(n: number): string {
189
190
  return String(n);
190
191
  }
191
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
+
192
211
  // ── Git dirty state (event-driven, not TTL) ───────────────────────
193
212
  // Refreshed at session_start and after every agent turn (turn_end).
194
213
  interface GitDirty {
@@ -198,15 +217,14 @@ interface GitDirty {
198
217
  }
199
218
  let _gitDirty: GitDirty | undefined;
200
219
 
201
- /** Parse `git status --porcelain` output into staged, unstaged, and untracked counts. */
202
- function parseGitPorcelain(stdout: string): GitDirty {
203
- const lines = stdout.trim();
204
- if (!lines) return { staged: 0, unstaged: 0, untracked: 0 };
205
-
220
+ /** Parse `git status --porcelain` output into staged, unstaged, and untracked counts.
221
+ * @internal exported for testing. */
222
+ export function parseGitPorcelain(stdout: string): GitDirty {
206
223
  let staged = 0;
207
224
  let unstaged = 0;
208
225
  let untracked = 0;
209
- for (const line of lines.split("\n")) {
226
+ // Leading spaces encode the index status and must be preserved.
227
+ for (const line of stdout.split("\n")) {
210
228
  if (line.length < 2) continue;
211
229
  const x = line[0];
212
230
  const y = line[1];
@@ -252,6 +270,60 @@ function refreshGitDirty(cwd: string, onDone?: () => void): void {
252
270
  child.on("close", (code) => settle(code === 0, stdout));
253
271
  }
254
272
 
273
+ /** Filesystem boundary for {@link linkedWorktreeName}, injectable so tests
274
+ * can exercise the walk-up and pointer parsing without touching the real
275
+ * filesystem. `isFile` distinguishes "missing" (undefined) from "exists but
276
+ * not a regular file" (false) — the walk-up continues past the former and
277
+ * stops at the latter (a `.git` directory = main worktree). */
278
+ export interface WorktreeIO {
279
+ isFile(p: string): boolean | undefined;
280
+ readText(p: string): string;
281
+ }
282
+
283
+ const NODE_IO: WorktreeIO = {
284
+ isFile(p) {
285
+ try {
286
+ return statSync(p).isFile();
287
+ } catch (e) {
288
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined;
289
+ throw e;
290
+ }
291
+ },
292
+ readText(p) {
293
+ return readFileSync(p, "utf8");
294
+ },
295
+ };
296
+
297
+ /** Name of the linked worktree containing `cwd`, or null when `cwd` sits in
298
+ * the main worktree or outside any repo. A linked worktree marks itself with
299
+ * a `.git` *file* pointing at `<main>/.git/worktrees/<name>`; the parent-dir
300
+ * check keeps submodule pointers (`.git/modules/…`) from masquerading as
301
+ * worktrees.
302
+ * @internal — exported for testing; the session reads it once at start. */
303
+ export function linkedWorktreeName(cwd: string, io: WorktreeIO = NODE_IO): string | null {
304
+ let dir = path.resolve(cwd);
305
+ for (;;) {
306
+ const dotGit = path.join(dir, ".git");
307
+ const kind = io.isFile(dotGit);
308
+ if (kind === undefined) {
309
+ const parent = path.dirname(dir);
310
+ if (parent === dir) return null;
311
+ dir = parent;
312
+ continue;
313
+ }
314
+ if (!kind) return null;
315
+ try {
316
+ const content = io.readText(dotGit).trim();
317
+ if (!content.startsWith("gitdir: ")) return null;
318
+ const gitdir = path.resolve(dir, content.slice("gitdir: ".length));
319
+ if (path.basename(path.dirname(gitdir)) !== "worktrees") return null;
320
+ return path.basename(gitdir) || null;
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+ }
326
+
255
327
  /** Format dirty state as "+staged ~unstaged *untracked" (leading space), or ""
256
328
  * if clean / unknown — ready to splice into a "(branch…)" segment. */
257
329
  function gitDirtyDisplay(): string {
@@ -283,17 +355,24 @@ export default function (pi: ExtensionAPI) {
283
355
  let footerSnap: FooterSnap | undefined;
284
356
  // CWD cached from session_start — used by turn_end to refresh git dirty.
285
357
  let _cwd = "";
358
+ // Linked-worktree name for the session cwd, resolved once at session_start
359
+ // (worktree names are stable for the lifetime of a session). Null = main
360
+ // worktree or outside any repo — no badge.
361
+ let _worktreeName: string | null = null;
286
362
  // cacheRead total + latest-turn usage, refreshed at session_start +
287
363
  // agent_end. The render provider reads these instead of re-scanning
288
364
  // entries every frame.
289
365
  let _cacheTotal = 0;
290
366
  let _latestUsage: UsageSnap | undefined;
291
- // Latest reliable client-observed text throughput. TTFT is excluded; tool-call,
292
- // failed, and statistically tiny responses do not replace the last valid sample.
293
- // Persisted messages have no delta timestamps, so resumed sessions start empty.
294
- let _firstTextDeltaAt: number | undefined;
295
- let _lastTextDeltaAt: number | undefined;
296
- let _latestTps: number | undefined;
367
+ // Client-observed timing for the current assistant response. The completed
368
+ // measurement replaces the previous turn even when throughput is unavailable,
369
+ // so the shell never displays stale performance data.
370
+ let _turnStartedAt: number | undefined;
371
+ let _firstVisibleTextAt: number | undefined;
372
+ let _responseEndedAt: number | undefined;
373
+ let _reasoningExpected = false;
374
+ let _sawThinking = false;
375
+ let _latestPerformance: ResponsePerformance | undefined;
297
376
  // Full-session billable cost, including tool/summarization usage. Zero means the
298
377
  // provider supplied no priced usage, so the border omits the dollar segment.
299
378
  let _sessionCost = 0;
@@ -301,19 +380,30 @@ export default function (pi: ExtensionAPI) {
301
380
  // ── Phase-aware spinner + lifecycle ────────────────────────────
302
381
  // Each event asks the editor for a phase; CardEditor.setSpinner is itself
303
382
  // a same-phase no-op, so rapid event streams never reset the animation.
383
+ pi.on("turn_start", (_event, ctx) => {
384
+ _turnStartedAt = performance.now();
385
+ _firstVisibleTextAt = undefined;
386
+ _responseEndedAt = undefined;
387
+ _reasoningExpected = Boolean(ctx.model?.reasoning && pi.getThinkingLevel() !== "off");
388
+ _sawThinking = false;
389
+ _latestPerformance = undefined;
390
+ editor?.setSpinner("thinking");
391
+ editor?.requestRender();
392
+ });
304
393
  pi.on("message_start", (event) => {
305
394
  if (event.message.role !== "assistant") return;
306
- _firstTextDeltaAt = undefined;
307
- _lastTextDeltaAt = undefined;
395
+ _firstVisibleTextAt = undefined;
396
+ _responseEndedAt = undefined;
397
+ _sawThinking = false;
308
398
  });
309
- pi.on("turn_start", () => editor?.setSpinner("thinking"));
310
399
  pi.on("message_update", (event) => {
311
- const t = event.assistantMessageEvent.type;
312
- if (t === "text_delta") {
313
- const now = performance.now();
314
- _firstTextDeltaAt ??= now;
315
- _lastTextDeltaAt = now;
400
+ const update = event.assistantMessageEvent;
401
+ const t = update.type;
402
+ if (t === "text_delta" && update.delta.length > 0) {
403
+ _firstVisibleTextAt ??= performance.now();
316
404
  }
405
+ if (t.startsWith("thinking_")) _sawThinking = true;
406
+
317
407
  let next: SpinnerPhase;
318
408
  if (t.startsWith("thinking_")) next = "thinking";
319
409
  else if (t.startsWith("text_")) next = "outputting";
@@ -321,6 +411,16 @@ export default function (pi: ExtensionAPI) {
321
411
  else return;
322
412
  editor?.setSpinner(next);
323
413
  });
414
+ pi.on("message_end", (event) => {
415
+ if (event.message.role !== "assistant") return;
416
+ _responseEndedAt = performance.now();
417
+ const message = event.message as MsgSnap;
418
+ const hasVisibleText = message.content?.some(
419
+ (part) => part.type === "text" && typeof part.text === "string" && part.text.length > 0,
420
+ ) ?? false;
421
+ // Non-streaming providers expose visible text only when the response completes.
422
+ if (hasVisibleText) _firstVisibleTextAt ??= _responseEndedAt;
423
+ });
324
424
  pi.on("tool_execution_start", () => editor?.setSpinner("exec"));
325
425
  pi.on("agent_end", (_event, ctx) => {
326
426
  // cacheRead totals + latest usage are stable once a turn finishes —
@@ -331,9 +431,12 @@ export default function (pi: ExtensionAPI) {
331
431
  editor?.setSpinner(null);
332
432
  });
333
433
  pi.on("session_shutdown", () => {
334
- _firstTextDeltaAt = undefined;
335
- _lastTextDeltaAt = undefined;
336
- _latestTps = undefined;
434
+ _turnStartedAt = undefined;
435
+ _firstVisibleTextAt = undefined;
436
+ _responseEndedAt = undefined;
437
+ _reasoningExpected = false;
438
+ _sawThinking = false;
439
+ _latestPerformance = undefined;
337
440
  _sessionCost = 0;
338
441
  editor?.setSpinner(null);
339
442
  editor = undefined;
@@ -352,17 +455,25 @@ export default function (pi: ExtensionAPI) {
352
455
  pi.on("turn_end", (event) => {
353
456
  if (event.message.role === "assistant") {
354
457
  const message = event.message as MsgSnap;
355
- const sample = calculateVisibleTextTps({
458
+ const hasVisibleText = message.content?.some(
459
+ (part) => part.type === "text" && typeof part.text === "string" && part.text.length > 0,
460
+ ) ?? false;
461
+ _latestPerformance = calculateResponsePerformance({
356
462
  outputTokens: message.usage?.output,
357
463
  reasoningTokens: message.usage?.reasoning,
358
- firstTextDeltaAt: _firstTextDeltaAt,
359
- lastTextDeltaAt: _lastTextDeltaAt,
464
+ reasoningExpected: _reasoningExpected || _sawThinking,
465
+ turnStartedAt: _turnStartedAt,
466
+ firstVisibleTextAt: _firstVisibleTextAt,
467
+ responseEndedAt: _responseEndedAt,
468
+ hasVisibleText,
360
469
  hasToolCall: message.content?.some((part) => part.type === "toolCall") ?? false,
361
470
  stopReason: message.stopReason,
362
471
  });
363
- if (sample != null) _latestTps = sample;
364
- _firstTextDeltaAt = undefined;
365
- _lastTextDeltaAt = undefined;
472
+ _turnStartedAt = undefined;
473
+ _firstVisibleTextAt = undefined;
474
+ _responseEndedAt = undefined;
475
+ _reasoningExpected = false;
476
+ _sawThinking = false;
366
477
  editor?.requestRender();
367
478
  }
368
479
  if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
@@ -372,13 +483,17 @@ export default function (pi: ExtensionAPI) {
372
483
  if (!ctx.hasUI) return;
373
484
 
374
485
  _cwd = ctx.cwd;
486
+ _worktreeName = linkedWorktreeName(ctx.cwd);
375
487
  config = loadEditorShellConfig(ctx.cwd);
376
488
  icons = { ...DEFAULT_ICONS, ...config.icons };
377
489
  _cacheTotal = sumCacheRead(ctx);
378
490
  _latestUsage = latestAssistantUsage(ctx);
379
- _firstTextDeltaAt = undefined;
380
- _lastTextDeltaAt = undefined;
381
- _latestTps = undefined;
491
+ _turnStartedAt = undefined;
492
+ _firstVisibleTextAt = undefined;
493
+ _responseEndedAt = undefined;
494
+ _reasoningExpected = false;
495
+ _sawThinking = false;
496
+ _latestPerformance = undefined;
382
497
  _sessionCost = sumSessionCost(ctx);
383
498
  refreshGitDirty(ctx.cwd, () => editor?.requestRender());
384
499
 
@@ -427,22 +542,30 @@ export default function (pi: ExtensionAPI) {
427
542
  _cacheTotal > 0
428
543
  ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${icons.cache} ${formatTokens(cacheReadNow)} (${formatTokens(_cacheTotal)})${hitRate != null ? ` ${icons.hitRate} ${hitRate.toFixed(1)}%` : ""}`)}`
429
544
  : "";
430
- const tpsPart =
431
- _latestTps != null
432
- ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${_latestTps.toFixed(1)} t/s`)}`
433
- : "";
545
+ const displayedTps = config.tpsDisplay === "end-to-end"
546
+ ? _latestPerformance?.e2eTps
547
+ : config.tpsDisplay === "generation"
548
+ ? _latestPerformance?.generationTps
549
+ : undefined;
550
+ const tpsLabel = config.tpsDisplay === "end-to-end" ? "e2e" : "gen";
551
+ const tpsPart = displayedTps != null
552
+ ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${formatTps(displayedTps)} ${tpsLabel} t/s`)}`
553
+ : "";
434
554
  const costPart =
435
555
  _sessionCost > 0
436
556
  ? `${theme.fg("dim", " · ")}${theme.fg("warning", `$${_sessionCost.toFixed(3)}`)}`
437
557
  : "";
438
558
 
439
- // Git branch + dirty state — pi's format: ~/Projects (main).
559
+ // Git branch + worktree badge + dirty state — pi's format:
560
+ // ~/Projects (main). Inside a linked worktree the branch carries an
561
+ // @<name> tag, so sibling worktrees of one repo are told apart at a glance.
440
562
  const cwdText = formatCwd(ctx.cwd);
441
563
  const branch = footerSnap?.getGitBranch() ?? null;
564
+ const worktreeTag = _worktreeName ? ` @${_worktreeName}` : "";
442
565
  const dirty = branch ? gitDirtyDisplay() : "";
443
566
  const cwdDisplay =
444
567
  branch && branch !== "detached"
445
- ? `${icons.folder} ${cwdText} (${branch}${dirty})`
568
+ ? `${icons.folder} ${cwdText} (${branch}${worktreeTag}${dirty})`
446
569
  : `${icons.folder} ${cwdText}`;
447
570
 
448
571
  // Model in accent; thinking label in its level token — same hue the
@@ -516,6 +639,7 @@ export default function (pi: ExtensionAPI) {
516
639
  lines.push("[editor-shell config]");
517
640
  lines.push(` pinnedStatus: [${config.pinnedStatus.join(", ")}]`);
518
641
  lines.push(` modelDisplay: ${config.modelDisplay}`);
642
+ lines.push(` tpsDisplay: ${config.tpsDisplay}`);
519
643
 
520
644
  lines.push("");
521
645
  lines.push("[extension statuses]");
@@ -549,13 +673,33 @@ export default function (pi: ExtensionAPI) {
549
673
  const sessionCost = sumSessionCost(ctx);
550
674
  _sessionCost = sessionCost;
551
675
  lines.push(` session cost: ${sessionCost > 0 ? `$${sessionCost.toFixed(3)}` : "n/a"}`);
552
- lines.push(` latest reliable text TPS: ${_latestTps != null ? `${_latestTps.toFixed(1)} t/s` : "n/a"}`);
553
676
  const latest = latestAssistantUsage(ctx);
554
677
  const now = latest?.cacheRead ?? 0;
555
678
  lines.push(` this turn cacheRead: ${formatTokens(now)}`);
556
679
  const hr = cacheHitRate(latest);
557
680
  lines.push(` this turn hit rate: ${hr != null ? `${hr.toFixed(1)}%` : "n/a"}`);
558
681
 
682
+ lines.push("");
683
+ lines.push("[response performance]");
684
+ const perf = _latestPerformance;
685
+ const throughputReason = perf?.throughputUnavailableReason;
686
+ const generationReason = throughputReason ?? perf?.generationUnavailableReason;
687
+ lines.push(` wait to first visible text: ${formatDuration(perf?.waitMs)}`);
688
+ lines.push(` total response time: ${formatDuration(perf?.totalMs)}`);
689
+ lines.push(
690
+ ` end-to-end throughput: ${perf?.e2eTps != null ? `${formatTps(perf.e2eTps)} t/s` : `n/a${throughputReason ? ` (${throughputReason})` : ""}`}`,
691
+ );
692
+ lines.push(
693
+ ` generation throughput: ${perf?.generationTps != null ? `${formatTps(perf.generationTps)} t/s` : `n/a${generationReason ? ` (${generationReason})` : ""}`}`,
694
+ );
695
+ lines.push(` visible output tokens: ${perf?.visibleTokens ?? "n/a"}`);
696
+ const tokenSource = perf?.tokenSource === "provider-output-minus-reasoning"
697
+ ? "provider output minus reasoning"
698
+ : perf?.tokenSource === "provider-output"
699
+ ? "provider output (reasoning not expected)"
700
+ : "n/a";
701
+ lines.push(` token source: ${tokenSource}`);
702
+
559
703
  lines.push("");
560
704
  lines.push(`[context] cwd: ${ctx.cwd}`);
561
705
  const branch = footerSnap?.getGitBranch();
@@ -563,6 +707,7 @@ export default function (pi: ExtensionAPI) {
563
707
  if (branch) {
564
708
  const dirty = gitDirtyDisplay().trim();
565
709
  lines.push(` git dirty: ${dirty || "clean"}`);
710
+ lines.push(` worktree: ${_worktreeName ?? "main"}`);
566
711
  }
567
712
  const m = ctx.model;
568
713
  lines.push(` model: ${m ? `${m.provider}/${m.id}:${pi.getThinkingLevel()}` : "none"}`);
package/src/tps.test.ts CHANGED
@@ -1,66 +1,136 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { calculateVisibleTextTps, type VisibleTextTpsSample } from "./tps.ts";
3
+ import {
4
+ calculateResponsePerformance,
5
+ type ResponsePerformanceSample,
6
+ } from "./tps.ts";
4
7
 
5
- function sample(overrides: Partial<VisibleTextTpsSample> = {}): VisibleTextTpsSample {
8
+ function sample(overrides: Partial<ResponsePerformanceSample> = {}): ResponsePerformanceSample {
6
9
  return {
7
- outputTokens: 51,
8
- reasoningTokens: 10,
9
- firstTextDeltaAt: 1_000,
10
- lastTextDeltaAt: 5_000,
10
+ outputTokens: 101,
11
+ reasoningTokens: 20,
12
+ reasoningExpected: true,
13
+ turnStartedAt: 1_000,
14
+ firstVisibleTextAt: 3_000,
15
+ responseEndedAt: 7_000,
16
+ hasVisibleText: true,
11
17
  hasToolCall: false,
12
18
  stopReason: "stop",
13
19
  ...overrides,
14
20
  };
15
21
  }
16
22
 
17
- test("calculates visible text TPS over inter-token intervals", () => {
18
- assert.equal(calculateVisibleTextTps(sample()), 10);
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
+ });
19
33
  });
20
34
 
21
- test("uses all output tokens when the provider has no reasoning breakdown", () => {
22
- assert.equal(calculateVisibleTextTps(sample({ reasoningTokens: undefined })), 12.5);
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");
23
47
  });
24
48
 
25
- test("accepts the minimum reliable sample", () => {
26
- assert.equal(
27
- calculateVisibleTextTps(sample({
28
- outputTokens: 10,
29
- reasoningTokens: 0,
30
- firstTextDeltaAt: 1_000,
31
- lastTextDeltaAt: 1_250,
32
- })),
33
- 36,
34
- );
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
+ }
35
61
  });
36
62
 
37
- test("rejects responses containing tool calls", () => {
38
- assert.equal(calculateVisibleTextTps(sample({ hasToolCall: true })), undefined);
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");
39
68
  });
40
69
 
41
- test("rejects failed and aborted responses", () => {
42
- assert.equal(calculateVisibleTextTps(sample({ stopReason: "error" })), undefined);
43
- assert.equal(calculateVisibleTextTps(sample({ stopReason: "aborted" })), undefined);
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
+ }
44
76
  });
45
77
 
46
- test("rejects samples with too few visible tokens", () => {
47
- assert.equal(
48
- calculateVisibleTextTps(sample({ outputTokens: 18, reasoningTokens: 9 })),
49
- undefined,
50
- );
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");
51
104
  });
52
105
 
53
- test("rejects samples shorter than 250 ms", () => {
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
+ );
54
124
  assert.equal(
55
- calculateVisibleTextTps(sample({ firstTextDeltaAt: 1_000, lastTextDeltaAt: 1_249 })),
56
- undefined,
125
+ calculateResponsePerformance(sample({ reasoningTokens: 102 })).throughputUnavailableReason,
126
+ "provider reasoning-token usage is invalid",
57
127
  );
58
128
  });
59
129
 
60
- test("rejects missing or invalid usage and timing data", () => {
61
- assert.equal(calculateVisibleTextTps(sample({ outputTokens: undefined })), undefined);
62
- assert.equal(calculateVisibleTextTps(sample({ firstTextDeltaAt: undefined })), undefined);
63
- assert.equal(calculateVisibleTextTps(sample({ lastTextDeltaAt: undefined })), undefined);
64
- assert.equal(calculateVisibleTextTps(sample({ reasoningTokens: -1 })), undefined);
65
- assert.equal(calculateVisibleTextTps(sample({ lastTextDeltaAt: Number.NaN })), undefined);
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");
66
136
  });
package/src/tps.ts CHANGED
@@ -1,53 +1,128 @@
1
- const MIN_VISIBLE_TOKENS = 10;
2
- const MIN_SAMPLE_DURATION_MS = 250;
1
+ const MIN_GENERATION_TOKENS = 10;
2
+ const MIN_GENERATION_DURATION_MS = 250;
3
+
4
+ export type TpsTokenSource = "provider-output" | "provider-output-minus-reasoning";
3
5
 
4
6
  /** @internal — exported for focused throughput tests. */
5
- export interface VisibleTextTpsSample {
7
+ export interface ResponsePerformanceSample {
6
8
  outputTokens?: number;
7
9
  reasoningTokens?: number;
8
- firstTextDeltaAt?: number;
9
- lastTextDeltaAt?: number;
10
+ reasoningExpected: boolean;
11
+ turnStartedAt?: number;
12
+ firstVisibleTextAt?: number;
13
+ responseEndedAt?: number;
14
+ hasVisibleText: boolean;
10
15
  hasToolCall: boolean;
11
16
  stopReason?: string;
12
17
  }
13
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
+
14
36
  /**
15
- * Calculate client-observed visible-text throughput for one completed response.
37
+ * Calculate client-observed response performance for one completed assistant message.
16
38
  *
17
- * The provider's reasoning count is removed when available because its tokens may
18
- * be generated before the first visible delta. Tool-call responses are excluded:
19
- * providers do not expose a portable split between text and serialized tool tokens.
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.
20
43
  *
21
44
  * @internal — exported for focused throughput tests.
22
45
  */
23
- export function calculateVisibleTextTps(sample: VisibleTextTpsSample): number | undefined {
24
- if (sample.hasToolCall || sample.stopReason === "error" || sample.stopReason === "aborted") {
25
- return undefined;
26
- }
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;
27
53
 
28
- const output = sample.outputTokens;
29
- const reasoning = sample.reasoningTokens ?? 0;
30
- const first = sample.firstTextDeltaAt;
31
- const last = sample.lastTextDeltaAt;
54
+ if (validTimestamp(start) && validTimestamp(end) && end >= start) {
55
+ result.totalMs = end - start;
56
+ }
32
57
  if (
33
- output == null ||
34
- !Number.isFinite(output) ||
35
- !Number.isFinite(reasoning) ||
36
- reasoning < 0 ||
37
- first == null ||
38
- last == null ||
39
- !Number.isFinite(first) ||
40
- !Number.isFinite(last)
58
+ validTimestamp(start) &&
59
+ validTimestamp(first) &&
60
+ validTimestamp(end) &&
61
+ first >= start &&
62
+ first <= end
41
63
  ) {
42
- return undefined;
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;
43
113
  }
114
+ result.e2eTps = visibleTokens / (result.totalMs / 1000);
44
115
 
45
- const visibleTokens = output - reasoning;
46
- const elapsedMs = last - first;
47
- if (visibleTokens < MIN_VISIBLE_TOKENS || elapsedMs < MIN_SAMPLE_DURATION_MS) {
48
- return undefined;
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);
49
125
  }
50
126
 
51
- // N received tokens span N - 1 inter-token intervals once TTFT is excluded.
52
- return (visibleTokens - 1) / (elapsedMs / 1000);
127
+ return result;
53
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
+ });