@d3ara1n/pi-editor-shell 0.9.0 → 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 +4 -4
- package/package.json +1 -1
- package/src/index.ts +33 -28
- package/src/tps.test.ts +66 -0
- package/src/tps.ts +53 -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). TPS is
|
|
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`.
|
|
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
|
|
|
@@ -54,8 +54,8 @@ How the model is labeled in the top-left border (`"name"` by default):
|
|
|
54
54
|
|
|
55
55
|
| Value | Example |
|
|
56
56
|
|-------|---------|
|
|
57
|
-
| `"name"` (default) | `Claude Opus 4.8
|
|
58
|
-
| `"provider-id"` | `
|
|
57
|
+
| `"name"` (default) | `Claude Opus 4.8` |
|
|
58
|
+
| `"provider-id"` | `anthropic/claude-opus-4-8` |
|
|
59
59
|
|
|
60
60
|
`"name"` uses `model.name`; a model with no name falls back to its id, so the slot never goes blank.
|
|
61
61
|
|
|
@@ -63,7 +63,7 @@ How the model is labeled in the top-left border (`"name"` by default):
|
|
|
63
63
|
|
|
64
64
|
| Command | Description |
|
|
65
65
|
|---------|-------------|
|
|
66
|
-
| `/editor-shell:status` | Show debug info: pinned config, all extension statuses with their keys, cache totals, latest
|
|
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 |
|
|
67
67
|
|
|
68
68
|
## How it works
|
|
69
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
|
|
@@ -75,6 +76,7 @@ const DEFAULT_ICONS: EditorShellIcons = {
|
|
|
75
76
|
interface UsageSnap {
|
|
76
77
|
input?: number;
|
|
77
78
|
output?: number;
|
|
79
|
+
reasoning?: number;
|
|
78
80
|
cacheRead?: number;
|
|
79
81
|
cacheWrite?: number;
|
|
80
82
|
cost?: {
|
|
@@ -83,7 +85,9 @@ interface UsageSnap {
|
|
|
83
85
|
}
|
|
84
86
|
interface MsgSnap {
|
|
85
87
|
role: string;
|
|
88
|
+
content?: Array<{ type: string }>;
|
|
86
89
|
usage?: UsageSnap;
|
|
90
|
+
stopReason?: string;
|
|
87
91
|
}
|
|
88
92
|
interface EntrySnap {
|
|
89
93
|
type: string;
|
|
@@ -284,11 +288,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
284
288
|
// entries every frame.
|
|
285
289
|
let _cacheTotal = 0;
|
|
286
290
|
let _latestUsage: UsageSnap | undefined;
|
|
287
|
-
// Latest
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
let
|
|
291
|
-
let
|
|
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;
|
|
292
296
|
let _latestTps: number | undefined;
|
|
293
297
|
// Full-session billable cost, including tool/summarization usage. Zero means the
|
|
294
298
|
// provider supplied no priced usage, so the border omits the dollar segment.
|
|
@@ -299,17 +303,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
299
303
|
// a same-phase no-op, so rapid event streams never reset the animation.
|
|
300
304
|
pi.on("message_start", (event) => {
|
|
301
305
|
if (event.message.role !== "assistant") return;
|
|
302
|
-
|
|
303
|
-
|
|
306
|
+
_firstTextDeltaAt = undefined;
|
|
307
|
+
_lastTextDeltaAt = undefined;
|
|
304
308
|
});
|
|
305
309
|
pi.on("turn_start", () => editor?.setSpinner("thinking"));
|
|
306
310
|
pi.on("message_update", (event) => {
|
|
307
311
|
const t = event.assistantMessageEvent.type;
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
_streamStartedAt = Date.now();
|
|
312
|
+
if (t === "text_delta") {
|
|
313
|
+
const now = performance.now();
|
|
314
|
+
_firstTextDeltaAt ??= now;
|
|
315
|
+
_lastTextDeltaAt = now;
|
|
313
316
|
}
|
|
314
317
|
let next: SpinnerPhase;
|
|
315
318
|
if (t.startsWith("thinking_")) next = "thinking";
|
|
@@ -318,12 +321,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
318
321
|
else return;
|
|
319
322
|
editor?.setSpinner(next);
|
|
320
323
|
});
|
|
321
|
-
pi.on("message_end", (event) => {
|
|
322
|
-
if (event.message.role !== "assistant") return;
|
|
323
|
-
const elapsedMs = _streamStartedAt == null ? 0 : Date.now() - _streamStartedAt;
|
|
324
|
-
_pendingResponseElapsedMs = elapsedMs > 0 ? elapsedMs : undefined;
|
|
325
|
-
_streamStartedAt = undefined;
|
|
326
|
-
});
|
|
327
324
|
pi.on("tool_execution_start", () => editor?.setSpinner("exec"));
|
|
328
325
|
pi.on("agent_end", (_event, ctx) => {
|
|
329
326
|
// cacheRead totals + latest usage are stable once a turn finishes —
|
|
@@ -334,8 +331,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
334
331
|
editor?.setSpinner(null);
|
|
335
332
|
});
|
|
336
333
|
pi.on("session_shutdown", () => {
|
|
337
|
-
|
|
338
|
-
|
|
334
|
+
_firstTextDeltaAt = undefined;
|
|
335
|
+
_lastTextDeltaAt = undefined;
|
|
339
336
|
_latestTps = undefined;
|
|
340
337
|
_sessionCost = 0;
|
|
341
338
|
editor?.setSpinner(null);
|
|
@@ -354,10 +351,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
354
351
|
// Async — never blocks the event loop; re-renders once settled.
|
|
355
352
|
pi.on("turn_end", (event) => {
|
|
356
353
|
if (event.message.role === "assistant") {
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
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;
|
|
361
366
|
editor?.requestRender();
|
|
362
367
|
}
|
|
363
368
|
if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
|
|
@@ -371,8 +376,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
371
376
|
icons = { ...DEFAULT_ICONS, ...config.icons };
|
|
372
377
|
_cacheTotal = sumCacheRead(ctx);
|
|
373
378
|
_latestUsage = latestAssistantUsage(ctx);
|
|
374
|
-
|
|
375
|
-
|
|
379
|
+
_firstTextDeltaAt = undefined;
|
|
380
|
+
_lastTextDeltaAt = undefined;
|
|
376
381
|
_latestTps = undefined;
|
|
377
382
|
_sessionCost = sumSessionCost(ctx);
|
|
378
383
|
refreshGitDirty(ctx.cwd, () => editor?.requestRender());
|
|
@@ -424,7 +429,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
424
429
|
: "";
|
|
425
430
|
const tpsPart =
|
|
426
431
|
_latestTps != null
|
|
427
|
-
? `${theme.fg("dim", " · ")}${theme.fg("
|
|
432
|
+
? `${theme.fg("dim", " · ")}${theme.fg("warning", `${_latestTps.toFixed(1)} t/s`)}`
|
|
428
433
|
: "";
|
|
429
434
|
const costPart =
|
|
430
435
|
_sessionCost > 0
|
|
@@ -544,7 +549,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
544
549
|
const sessionCost = sumSessionCost(ctx);
|
|
545
550
|
_sessionCost = sessionCost;
|
|
546
551
|
lines.push(` session cost: ${sessionCost > 0 ? `$${sessionCost.toFixed(3)}` : "n/a"}`);
|
|
547
|
-
lines.push(` latest
|
|
552
|
+
lines.push(` latest reliable text TPS: ${_latestTps != null ? `${_latestTps.toFixed(1)} t/s` : "n/a"}`);
|
|
548
553
|
const latest = latestAssistantUsage(ctx);
|
|
549
554
|
const now = latest?.cacheRead ?? 0;
|
|
550
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
|
+
}
|