@d3ara1n/pi-editor-shell 0.9.1 → 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 +23 -2
- package/package.json +1 -1
- package/src/config.ts +17 -0
- package/src/index.ts +181 -35
- package/src/tps.test.ts +109 -39
- package/src/tps.ts +107 -32
- package/src/worktree.test.ts +63 -0
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
|
|
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:
|
|
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
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,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 {
|
|
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 {
|
|
@@ -252,6 +271,60 @@ function refreshGitDirty(cwd: string, onDone?: () => void): void {
|
|
|
252
271
|
child.on("close", (code) => settle(code === 0, stdout));
|
|
253
272
|
}
|
|
254
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
|
+
|
|
255
328
|
/** Format dirty state as "+staged ~unstaged *untracked" (leading space), or ""
|
|
256
329
|
* if clean / unknown — ready to splice into a "(branch…)" segment. */
|
|
257
330
|
function gitDirtyDisplay(): string {
|
|
@@ -283,17 +356,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
283
356
|
let footerSnap: FooterSnap | undefined;
|
|
284
357
|
// CWD cached from session_start — used by turn_end to refresh git dirty.
|
|
285
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;
|
|
286
363
|
// cacheRead total + latest-turn usage, refreshed at session_start +
|
|
287
364
|
// agent_end. The render provider reads these instead of re-scanning
|
|
288
365
|
// entries every frame.
|
|
289
366
|
let _cacheTotal = 0;
|
|
290
367
|
let _latestUsage: UsageSnap | undefined;
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
let
|
|
295
|
-
let
|
|
296
|
-
let
|
|
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;
|
|
297
377
|
// Full-session billable cost, including tool/summarization usage. Zero means the
|
|
298
378
|
// provider supplied no priced usage, so the border omits the dollar segment.
|
|
299
379
|
let _sessionCost = 0;
|
|
@@ -301,19 +381,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
301
381
|
// ── Phase-aware spinner + lifecycle ────────────────────────────
|
|
302
382
|
// Each event asks the editor for a phase; CardEditor.setSpinner is itself
|
|
303
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
|
+
});
|
|
304
394
|
pi.on("message_start", (event) => {
|
|
305
395
|
if (event.message.role !== "assistant") return;
|
|
306
|
-
|
|
307
|
-
|
|
396
|
+
_firstVisibleTextAt = undefined;
|
|
397
|
+
_responseEndedAt = undefined;
|
|
398
|
+
_sawThinking = false;
|
|
308
399
|
});
|
|
309
|
-
pi.on("turn_start", () => editor?.setSpinner("thinking"));
|
|
310
400
|
pi.on("message_update", (event) => {
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
_lastTextDeltaAt = now;
|
|
401
|
+
const update = event.assistantMessageEvent;
|
|
402
|
+
const t = update.type;
|
|
403
|
+
if (t === "text_delta" && update.delta.length > 0) {
|
|
404
|
+
_firstVisibleTextAt ??= performance.now();
|
|
316
405
|
}
|
|
406
|
+
if (t.startsWith("thinking_")) _sawThinking = true;
|
|
407
|
+
|
|
317
408
|
let next: SpinnerPhase;
|
|
318
409
|
if (t.startsWith("thinking_")) next = "thinking";
|
|
319
410
|
else if (t.startsWith("text_")) next = "outputting";
|
|
@@ -321,6 +412,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
412
|
else return;
|
|
322
413
|
editor?.setSpinner(next);
|
|
323
414
|
});
|
|
415
|
+
pi.on("message_end", (event) => {
|
|
416
|
+
if (event.message.role !== "assistant") return;
|
|
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;
|
|
424
|
+
});
|
|
324
425
|
pi.on("tool_execution_start", () => editor?.setSpinner("exec"));
|
|
325
426
|
pi.on("agent_end", (_event, ctx) => {
|
|
326
427
|
// cacheRead totals + latest usage are stable once a turn finishes —
|
|
@@ -331,9 +432,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
331
432
|
editor?.setSpinner(null);
|
|
332
433
|
});
|
|
333
434
|
pi.on("session_shutdown", () => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
435
|
+
_turnStartedAt = undefined;
|
|
436
|
+
_firstVisibleTextAt = undefined;
|
|
437
|
+
_responseEndedAt = undefined;
|
|
438
|
+
_reasoningExpected = false;
|
|
439
|
+
_sawThinking = false;
|
|
440
|
+
_latestPerformance = undefined;
|
|
337
441
|
_sessionCost = 0;
|
|
338
442
|
editor?.setSpinner(null);
|
|
339
443
|
editor = undefined;
|
|
@@ -352,17 +456,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
352
456
|
pi.on("turn_end", (event) => {
|
|
353
457
|
if (event.message.role === "assistant") {
|
|
354
458
|
const message = event.message as MsgSnap;
|
|
355
|
-
const
|
|
459
|
+
const hasVisibleText = message.content?.some(
|
|
460
|
+
(part) => part.type === "text" && typeof part.text === "string" && part.text.length > 0,
|
|
461
|
+
) ?? false;
|
|
462
|
+
_latestPerformance = calculateResponsePerformance({
|
|
356
463
|
outputTokens: message.usage?.output,
|
|
357
464
|
reasoningTokens: message.usage?.reasoning,
|
|
358
|
-
|
|
359
|
-
|
|
465
|
+
reasoningExpected: _reasoningExpected || _sawThinking,
|
|
466
|
+
turnStartedAt: _turnStartedAt,
|
|
467
|
+
firstVisibleTextAt: _firstVisibleTextAt,
|
|
468
|
+
responseEndedAt: _responseEndedAt,
|
|
469
|
+
hasVisibleText,
|
|
360
470
|
hasToolCall: message.content?.some((part) => part.type === "toolCall") ?? false,
|
|
361
471
|
stopReason: message.stopReason,
|
|
362
472
|
});
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
473
|
+
_turnStartedAt = undefined;
|
|
474
|
+
_firstVisibleTextAt = undefined;
|
|
475
|
+
_responseEndedAt = undefined;
|
|
476
|
+
_reasoningExpected = false;
|
|
477
|
+
_sawThinking = false;
|
|
366
478
|
editor?.requestRender();
|
|
367
479
|
}
|
|
368
480
|
if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
|
|
@@ -372,13 +484,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
372
484
|
if (!ctx.hasUI) return;
|
|
373
485
|
|
|
374
486
|
_cwd = ctx.cwd;
|
|
487
|
+
_worktreeName = linkedWorktreeName(ctx.cwd);
|
|
375
488
|
config = loadEditorShellConfig(ctx.cwd);
|
|
376
489
|
icons = { ...DEFAULT_ICONS, ...config.icons };
|
|
377
490
|
_cacheTotal = sumCacheRead(ctx);
|
|
378
491
|
_latestUsage = latestAssistantUsage(ctx);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
492
|
+
_turnStartedAt = undefined;
|
|
493
|
+
_firstVisibleTextAt = undefined;
|
|
494
|
+
_responseEndedAt = undefined;
|
|
495
|
+
_reasoningExpected = false;
|
|
496
|
+
_sawThinking = false;
|
|
497
|
+
_latestPerformance = undefined;
|
|
382
498
|
_sessionCost = sumSessionCost(ctx);
|
|
383
499
|
refreshGitDirty(ctx.cwd, () => editor?.requestRender());
|
|
384
500
|
|
|
@@ -427,22 +543,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
427
543
|
_cacheTotal > 0
|
|
428
544
|
? `${theme.fg("dim", " · ")}${theme.fg("warning", `${icons.cache} ${formatTokens(cacheReadNow)} (${formatTokens(_cacheTotal)})${hitRate != null ? ` ${icons.hitRate} ${hitRate.toFixed(1)}%` : ""}`)}`
|
|
429
545
|
: "";
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
: "";
|
|
434
555
|
const costPart =
|
|
435
556
|
_sessionCost > 0
|
|
436
557
|
? `${theme.fg("dim", " · ")}${theme.fg("warning", `$${_sessionCost.toFixed(3)}`)}`
|
|
437
558
|
: "";
|
|
438
559
|
|
|
439
|
-
// Git branch + dirty state — pi's format:
|
|
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.
|
|
440
563
|
const cwdText = formatCwd(ctx.cwd);
|
|
441
564
|
const branch = footerSnap?.getGitBranch() ?? null;
|
|
565
|
+
const worktreeTag = _worktreeName ? ` @${_worktreeName}` : "";
|
|
442
566
|
const dirty = branch ? gitDirtyDisplay() : "";
|
|
443
567
|
const cwdDisplay =
|
|
444
568
|
branch && branch !== "detached"
|
|
445
|
-
? `${icons.folder} ${cwdText} (${branch}${dirty})`
|
|
569
|
+
? `${icons.folder} ${cwdText} (${branch}${worktreeTag}${dirty})`
|
|
446
570
|
: `${icons.folder} ${cwdText}`;
|
|
447
571
|
|
|
448
572
|
// Model in accent; thinking label in its level token — same hue the
|
|
@@ -516,6 +640,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
516
640
|
lines.push("[editor-shell config]");
|
|
517
641
|
lines.push(` pinnedStatus: [${config.pinnedStatus.join(", ")}]`);
|
|
518
642
|
lines.push(` modelDisplay: ${config.modelDisplay}`);
|
|
643
|
+
lines.push(` tpsDisplay: ${config.tpsDisplay}`);
|
|
519
644
|
|
|
520
645
|
lines.push("");
|
|
521
646
|
lines.push("[extension statuses]");
|
|
@@ -549,13 +674,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
549
674
|
const sessionCost = sumSessionCost(ctx);
|
|
550
675
|
_sessionCost = sessionCost;
|
|
551
676
|
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
677
|
const latest = latestAssistantUsage(ctx);
|
|
554
678
|
const now = latest?.cacheRead ?? 0;
|
|
555
679
|
lines.push(` this turn cacheRead: ${formatTokens(now)}`);
|
|
556
680
|
const hr = cacheHitRate(latest);
|
|
557
681
|
lines.push(` this turn hit rate: ${hr != null ? `${hr.toFixed(1)}%` : "n/a"}`);
|
|
558
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
|
+
|
|
559
704
|
lines.push("");
|
|
560
705
|
lines.push(`[context] cwd: ${ctx.cwd}`);
|
|
561
706
|
const branch = footerSnap?.getGitBranch();
|
|
@@ -563,6 +708,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
563
708
|
if (branch) {
|
|
564
709
|
const dirty = gitDirtyDisplay().trim();
|
|
565
710
|
lines.push(` git dirty: ${dirty || "clean"}`);
|
|
711
|
+
lines.push(` worktree: ${_worktreeName ?? "main"}`);
|
|
566
712
|
}
|
|
567
713
|
const m = ctx.model;
|
|
568
714
|
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 {
|
|
3
|
+
import {
|
|
4
|
+
calculateResponsePerformance,
|
|
5
|
+
type ResponsePerformanceSample,
|
|
6
|
+
} from "./tps.ts";
|
|
4
7
|
|
|
5
|
-
function sample(overrides: Partial<
|
|
8
|
+
function sample(overrides: Partial<ResponsePerformanceSample> = {}): ResponsePerformanceSample {
|
|
6
9
|
return {
|
|
7
|
-
outputTokens:
|
|
8
|
-
reasoningTokens:
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
18
|
-
assert.
|
|
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
|
|
22
|
-
|
|
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("
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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("
|
|
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
|
-
|
|
56
|
-
|
|
125
|
+
calculateResponsePerformance(sample({ reasoningTokens: 102 })).throughputUnavailableReason,
|
|
126
|
+
"provider reasoning-token usage is invalid",
|
|
57
127
|
);
|
|
58
128
|
});
|
|
59
129
|
|
|
60
|
-
test("rejects
|
|
61
|
-
|
|
62
|
-
assert.equal(
|
|
63
|
-
assert.equal(
|
|
64
|
-
assert.equal(
|
|
65
|
-
assert.equal(
|
|
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
|
|
2
|
-
const
|
|
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
|
|
7
|
+
export interface ResponsePerformanceSample {
|
|
6
8
|
outputTokens?: number;
|
|
7
9
|
reasoningTokens?: number;
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
37
|
+
* Calculate client-observed response performance for one completed assistant message.
|
|
16
38
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const last = sample.lastTextDeltaAt;
|
|
54
|
+
if (validTimestamp(start) && validTimestamp(end) && end >= start) {
|
|
55
|
+
result.totalMs = end - start;
|
|
56
|
+
}
|
|
32
57
|
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
first
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
if (visibleTokens <
|
|
48
|
-
|
|
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
|
-
|
|
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
|
+
});
|