@d3ara1n/pi-editor-shell 0.8.3 → 0.9.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 +6 -4
- package/package.json +1 -1
- package/src/index.ts +94 -3
- package/src/tps.test.ts +66 -0
- package/src/tps.ts +53 -0
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# pi-editor-shell
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@d3ara1n/pi-editor-shell) [](https://www.npmjs.com/package/@d3ara1n/pi-editor-shell) [](https://www.npmjs.com/package/@d3ara1n/pi-editor-shell)
|
|
4
|
+
|
|
3
5
|
Replaces pi's default editor and status bar with a unified rounded-corner shell drawn with box-drawing glyphs (`╭╮││╰╯`), with status info embedded in the border. The frame and spinner use only standard Unicode; the six border icons are Nerd Font glyphs (overridable — see [Configuration](#configuration)).
|
|
4
6
|
|
|
5
7
|
## What shows up where
|
|
6
8
|
|
|
7
9
|
- **Top border** — ` model · thinking-level ` (left) + pinned extension statuses (right, via `pinnedStatus` config)
|
|
8
|
-
- **Bottom border** — ` ctx NN%/NNk|N.NM · ⚡ cacheRead (total) hitRate% ` (left) + ` ~/Projects (main +2 ~1 *4) ` (right, shows git branch plus staged, unstaged, and untracked file counts when in a repo). Session hit rate via `/editor-shell:status`.
|
|
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`.
|
|
9
11
|
- **Below shell** — Auto-wrapping extension status line (all `setStatus` entries not pinned to the top)
|
|
10
12
|
- **Border color** follows pi's thinking-level / bash-mode indicator automatically.
|
|
11
13
|
|
|
@@ -52,8 +54,8 @@ How the model is labeled in the top-left border (`"name"` by default):
|
|
|
52
54
|
|
|
53
55
|
| Value | Example |
|
|
54
56
|
|-------|---------|
|
|
55
|
-
| `"name"` (default) | `Claude Opus 4.8
|
|
56
|
-
| `"provider-id"` | `
|
|
57
|
+
| `"name"` (default) | `Claude Opus 4.8` |
|
|
58
|
+
| `"provider-id"` | `anthropic/claude-opus-4-8` |
|
|
57
59
|
|
|
58
60
|
`"name"` uses `model.name`; a model with no name falls back to its id, so the slot never goes blank.
|
|
59
61
|
|
|
@@ -61,7 +63,7 @@ How the model is labeled in the top-left border (`"name"` by default):
|
|
|
61
63
|
|
|
62
64
|
| Command | Description |
|
|
63
65
|
|---------|-------------|
|
|
64
|
-
| `/editor-shell:status` | Show debug info: pinned config, all extension statuses with their keys, cache totals |
|
|
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 |
|
|
65
67
|
|
|
66
68
|
## How it works
|
|
67
69
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import * as os from "node:os";
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { CardEditor, type FrameProvider, type SpinnerPhase } from "./card-editor.ts";
|
|
7
7
|
import { DEFAULT_CONFIG, loadEditorShellConfig, type EditorShellConfig, type EditorShellIcons } from "./config.ts";
|
|
8
|
+
import { calculateVisibleTextTps } from "./tps.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* pi-editor-shell — Replaces pi's default editor and status bar with a
|
|
@@ -74,16 +75,24 @@ const DEFAULT_ICONS: EditorShellIcons = {
|
|
|
74
75
|
* full pi-ai message union tree. */
|
|
75
76
|
interface UsageSnap {
|
|
76
77
|
input?: number;
|
|
78
|
+
output?: number;
|
|
79
|
+
reasoning?: number;
|
|
77
80
|
cacheRead?: number;
|
|
78
81
|
cacheWrite?: number;
|
|
82
|
+
cost?: {
|
|
83
|
+
total?: number;
|
|
84
|
+
};
|
|
79
85
|
}
|
|
80
86
|
interface MsgSnap {
|
|
81
87
|
role: string;
|
|
88
|
+
content?: Array<{ type: string }>;
|
|
82
89
|
usage?: UsageSnap;
|
|
90
|
+
stopReason?: string;
|
|
83
91
|
}
|
|
84
92
|
interface EntrySnap {
|
|
85
93
|
type: string;
|
|
86
94
|
message?: MsgSnap;
|
|
95
|
+
usage?: UsageSnap;
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
/** Sum cache-related usage across all assistant messages on the session.
|
|
@@ -102,6 +111,25 @@ function sumSessionUsage(ctx: { sessionManager: { getEntries(): unknown[] } }):
|
|
|
102
111
|
return { input, cacheRead, cacheWrite };
|
|
103
112
|
}
|
|
104
113
|
|
|
114
|
+
/** Sum billable usage across the full session, matching pi's built-in footer:
|
|
115
|
+
* assistant responses, usage-bearing tool results, compactions, and branch
|
|
116
|
+
* summaries. Providers without configured pricing report zero cost. */
|
|
117
|
+
function sumSessionCost(ctx: { sessionManager: { getEntries(): unknown[] } }): number {
|
|
118
|
+
let total = 0;
|
|
119
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
120
|
+
const e = entry as EntrySnap;
|
|
121
|
+
let usage: UsageSnap | undefined;
|
|
122
|
+
if (e.type === "message" && (e.message?.role === "assistant" || e.message?.role === "toolResult")) {
|
|
123
|
+
usage = e.message.usage;
|
|
124
|
+
} else if (e.type === "compaction" || e.type === "branch_summary") {
|
|
125
|
+
usage = e.usage;
|
|
126
|
+
}
|
|
127
|
+
const cost = usage?.cost?.total;
|
|
128
|
+
if (typeof cost === "number" && Number.isFinite(cost)) total += cost;
|
|
129
|
+
}
|
|
130
|
+
return total;
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
/** Sum cache-read tokens across all assistant messages — session total for
|
|
106
134
|
* the "(14.0M)" display in the border. Kept separate from sumSessionUsage
|
|
107
135
|
* to keep the hot path (agent_end) minimal. */
|
|
@@ -260,13 +288,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
260
288
|
// entries every frame.
|
|
261
289
|
let _cacheTotal = 0;
|
|
262
290
|
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;
|
|
297
|
+
// Full-session billable cost, including tool/summarization usage. Zero means the
|
|
298
|
+
// provider supplied no priced usage, so the border omits the dollar segment.
|
|
299
|
+
let _sessionCost = 0;
|
|
263
300
|
|
|
264
301
|
// ── Phase-aware spinner + lifecycle ────────────────────────────
|
|
265
302
|
// Each event asks the editor for a phase; CardEditor.setSpinner is itself
|
|
266
303
|
// a same-phase no-op, so rapid event streams never reset the animation.
|
|
304
|
+
pi.on("message_start", (event) => {
|
|
305
|
+
if (event.message.role !== "assistant") return;
|
|
306
|
+
_firstTextDeltaAt = undefined;
|
|
307
|
+
_lastTextDeltaAt = undefined;
|
|
308
|
+
});
|
|
267
309
|
pi.on("turn_start", () => editor?.setSpinner("thinking"));
|
|
268
310
|
pi.on("message_update", (event) => {
|
|
269
311
|
const t = event.assistantMessageEvent.type;
|
|
312
|
+
if (t === "text_delta") {
|
|
313
|
+
const now = performance.now();
|
|
314
|
+
_firstTextDeltaAt ??= now;
|
|
315
|
+
_lastTextDeltaAt = now;
|
|
316
|
+
}
|
|
270
317
|
let next: SpinnerPhase;
|
|
271
318
|
if (t.startsWith("thinking_")) next = "thinking";
|
|
272
319
|
else if (t.startsWith("text_")) next = "outputting";
|
|
@@ -280,16 +327,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
280
327
|
// recompute here instead of on every render frame.
|
|
281
328
|
_cacheTotal = sumCacheRead(ctx);
|
|
282
329
|
_latestUsage = latestAssistantUsage(ctx);
|
|
330
|
+
_sessionCost = sumSessionCost(ctx);
|
|
283
331
|
editor?.setSpinner(null);
|
|
284
332
|
});
|
|
285
333
|
pi.on("session_shutdown", () => {
|
|
334
|
+
_firstTextDeltaAt = undefined;
|
|
335
|
+
_lastTextDeltaAt = undefined;
|
|
336
|
+
_latestTps = undefined;
|
|
337
|
+
_sessionCost = 0;
|
|
286
338
|
editor?.setSpinner(null);
|
|
287
339
|
editor = undefined;
|
|
288
340
|
});
|
|
341
|
+
pi.on("session_compact", (_event, ctx) => {
|
|
342
|
+
_sessionCost = sumSessionCost(ctx);
|
|
343
|
+
editor?.requestRender();
|
|
344
|
+
});
|
|
345
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
346
|
+
_sessionCost = sumSessionCost(ctx);
|
|
347
|
+
editor?.requestRender();
|
|
348
|
+
});
|
|
289
349
|
|
|
290
350
|
// Refresh git dirty after every agent turn (tools may have changed files).
|
|
291
351
|
// Async — never blocks the event loop; re-renders once settled.
|
|
292
|
-
pi.on("turn_end", () => {
|
|
352
|
+
pi.on("turn_end", (event) => {
|
|
353
|
+
if (event.message.role === "assistant") {
|
|
354
|
+
const message = event.message as MsgSnap;
|
|
355
|
+
const sample = calculateVisibleTextTps({
|
|
356
|
+
outputTokens: message.usage?.output,
|
|
357
|
+
reasoningTokens: message.usage?.reasoning,
|
|
358
|
+
firstTextDeltaAt: _firstTextDeltaAt,
|
|
359
|
+
lastTextDeltaAt: _lastTextDeltaAt,
|
|
360
|
+
hasToolCall: message.content?.some((part) => part.type === "toolCall") ?? false,
|
|
361
|
+
stopReason: message.stopReason,
|
|
362
|
+
});
|
|
363
|
+
if (sample != null) _latestTps = sample;
|
|
364
|
+
_firstTextDeltaAt = undefined;
|
|
365
|
+
_lastTextDeltaAt = undefined;
|
|
366
|
+
editor?.requestRender();
|
|
367
|
+
}
|
|
293
368
|
if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
|
|
294
369
|
});
|
|
295
370
|
|
|
@@ -301,6 +376,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
301
376
|
icons = { ...DEFAULT_ICONS, ...config.icons };
|
|
302
377
|
_cacheTotal = sumCacheRead(ctx);
|
|
303
378
|
_latestUsage = latestAssistantUsage(ctx);
|
|
379
|
+
_firstTextDeltaAt = undefined;
|
|
380
|
+
_lastTextDeltaAt = undefined;
|
|
381
|
+
_latestTps = undefined;
|
|
382
|
+
_sessionCost = sumSessionCost(ctx);
|
|
304
383
|
refreshGitDirty(ctx.cwd, () => editor?.requestRender());
|
|
305
384
|
|
|
306
385
|
// Fresh segments on every render — reads live ctx state, so thinking /
|
|
@@ -348,6 +427,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
348
427
|
_cacheTotal > 0
|
|
349
428
|
? `${theme.fg("dim", " · ")}${theme.fg("warning", `${icons.cache} ${formatTokens(cacheReadNow)} (${formatTokens(_cacheTotal)})${hitRate != null ? ` ${icons.hitRate} ${hitRate.toFixed(1)}%` : ""}`)}`
|
|
350
429
|
: "";
|
|
430
|
+
const tpsPart =
|
|
431
|
+
_latestTps != null
|
|
432
|
+
? `${theme.fg("dim", " · ")}${theme.fg("warning", `${_latestTps.toFixed(1)} t/s`)}`
|
|
433
|
+
: "";
|
|
434
|
+
const costPart =
|
|
435
|
+
_sessionCost > 0
|
|
436
|
+
? `${theme.fg("dim", " · ")}${theme.fg("warning", `$${_sessionCost.toFixed(3)}`)}`
|
|
437
|
+
: "";
|
|
351
438
|
|
|
352
439
|
// Git branch + dirty state — pi's format: ~/Projects (main).
|
|
353
440
|
const cwdText = formatCwd(ctx.cwd);
|
|
@@ -364,7 +451,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
364
451
|
topLeft: ` ${theme.fg("accent", `${icons.model} ${model}`)}${theme.fg("dim", " · ")}${theme.fg(thinkingColor, `${icons.thinking} ${thinking}`)} `,
|
|
365
452
|
topRight: buildPinned(),
|
|
366
453
|
// Context in severity color; cwd stays muted so it never competes.
|
|
367
|
-
bottomLeft: ` ${theme.fg(contextToken(pct), `${icons.context} ${ctxText}`)}${cachePart} `,
|
|
454
|
+
bottomLeft: ` ${theme.fg(contextToken(pct), `${icons.context} ${ctxText}`)}${cachePart}${tpsPart}${costPart} `,
|
|
368
455
|
bottomRight: theme.fg("muted", ` ${cwdDisplay} `),
|
|
369
456
|
};
|
|
370
457
|
};
|
|
@@ -418,7 +505,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
418
505
|
|
|
419
506
|
// ── Debug command ──────────────────────────────────────────────
|
|
420
507
|
pi.registerCommand("editor-shell:status", {
|
|
421
|
-
description: "Show editor-shell debug state: status keys,
|
|
508
|
+
description: "Show editor-shell debug state: status keys, performance, and usage totals",
|
|
422
509
|
handler: async (_args, ctx) => {
|
|
423
510
|
// Refresh git dirty first so the status output reflects the current
|
|
424
511
|
// working tree — the event-driven cache is otherwise only updated at
|
|
@@ -459,6 +546,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
459
546
|
lines.push(` session cacheWrite: ${formatTokens(sess.cacheWrite ?? 0)}`);
|
|
460
547
|
const sRate = cacheHitRate(sess);
|
|
461
548
|
lines.push(` session hit rate: ${sRate != null ? `${sRate.toFixed(1)}%` : "n/a"}`);
|
|
549
|
+
const sessionCost = sumSessionCost(ctx);
|
|
550
|
+
_sessionCost = sessionCost;
|
|
551
|
+
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"}`);
|
|
462
553
|
const latest = latestAssistantUsage(ctx);
|
|
463
554
|
const now = latest?.cacheRead ?? 0;
|
|
464
555
|
lines.push(` this turn cacheRead: ${formatTokens(now)}`);
|
package/src/tps.test.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { calculateVisibleTextTps, type VisibleTextTpsSample } from "./tps.ts";
|
|
4
|
+
|
|
5
|
+
function sample(overrides: Partial<VisibleTextTpsSample> = {}): VisibleTextTpsSample {
|
|
6
|
+
return {
|
|
7
|
+
outputTokens: 51,
|
|
8
|
+
reasoningTokens: 10,
|
|
9
|
+
firstTextDeltaAt: 1_000,
|
|
10
|
+
lastTextDeltaAt: 5_000,
|
|
11
|
+
hasToolCall: false,
|
|
12
|
+
stopReason: "stop",
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("calculates visible text TPS over inter-token intervals", () => {
|
|
18
|
+
assert.equal(calculateVisibleTextTps(sample()), 10);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("uses all output tokens when the provider has no reasoning breakdown", () => {
|
|
22
|
+
assert.equal(calculateVisibleTextTps(sample({ reasoningTokens: undefined })), 12.5);
|
|
23
|
+
});
|
|
24
|
+
|
|
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
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rejects responses containing tool calls", () => {
|
|
38
|
+
assert.equal(calculateVisibleTextTps(sample({ hasToolCall: true })), undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("rejects failed and aborted responses", () => {
|
|
42
|
+
assert.equal(calculateVisibleTextTps(sample({ stopReason: "error" })), undefined);
|
|
43
|
+
assert.equal(calculateVisibleTextTps(sample({ stopReason: "aborted" })), undefined);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects samples with too few visible tokens", () => {
|
|
47
|
+
assert.equal(
|
|
48
|
+
calculateVisibleTextTps(sample({ outputTokens: 18, reasoningTokens: 9 })),
|
|
49
|
+
undefined,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("rejects samples shorter than 250 ms", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
calculateVisibleTextTps(sample({ firstTextDeltaAt: 1_000, lastTextDeltaAt: 1_249 })),
|
|
56
|
+
undefined,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
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);
|
|
66
|
+
});
|
package/src/tps.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const MIN_VISIBLE_TOKENS = 10;
|
|
2
|
+
const MIN_SAMPLE_DURATION_MS = 250;
|
|
3
|
+
|
|
4
|
+
/** @internal — exported for focused throughput tests. */
|
|
5
|
+
export interface VisibleTextTpsSample {
|
|
6
|
+
outputTokens?: number;
|
|
7
|
+
reasoningTokens?: number;
|
|
8
|
+
firstTextDeltaAt?: number;
|
|
9
|
+
lastTextDeltaAt?: number;
|
|
10
|
+
hasToolCall: boolean;
|
|
11
|
+
stopReason?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Calculate client-observed visible-text throughput for one completed response.
|
|
16
|
+
*
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* @internal — exported for focused throughput tests.
|
|
22
|
+
*/
|
|
23
|
+
export function calculateVisibleTextTps(sample: VisibleTextTpsSample): number | undefined {
|
|
24
|
+
if (sample.hasToolCall || sample.stopReason === "error" || sample.stopReason === "aborted") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const output = sample.outputTokens;
|
|
29
|
+
const reasoning = sample.reasoningTokens ?? 0;
|
|
30
|
+
const first = sample.firstTextDeltaAt;
|
|
31
|
+
const last = sample.lastTextDeltaAt;
|
|
32
|
+
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)
|
|
41
|
+
) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const visibleTokens = output - reasoning;
|
|
46
|
+
const elapsedMs = last - first;
|
|
47
|
+
if (visibleTokens < MIN_VISIBLE_TOKENS || elapsedMs < MIN_SAMPLE_DURATION_MS) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// N received tokens span N - 1 inter-token intervals once TTFT is excluded.
|
|
52
|
+
return (visibleTokens - 1) / (elapsedMs / 1000);
|
|
53
|
+
}
|