@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 CHANGED
@@ -7,7 +7,7 @@ Replaces pi's default editor and status bar with a unified rounded-corner shell
7
7
  ## What shows up where
8
8
 
9
9
  - **Top border** — `  model ·  thinking-level ` (left) + pinned extension statuses (right, via `pinnedStatus` config)
10
- - **Bottom border** — `  ctx NN%/NNk|N.NM · ⚡ cacheRead (total)  hitRate% · NN.N t/s · $N.NNN ` (left) + `  ~/Projects (main +2 ~1 *4) ` (right, shows git branch plus staged, unstaged, and untracked file counts when in a repo). TPS is measured for the latest completed response from its first streamed content delta, excluding time-to-first-token. Session cost includes assistant, tool, compaction, and branch-summary usage; the dollar segment is hidden when the provider reports no priced usage. Session hit rate via `/editor-shell:status`.
10
+ - **Bottom border** — `  ctx NN%/NNk|N.NM · ⚡ cacheRead (total)  hitRate% · NN.N 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 (Yanproxy)` |
58
- | `"provider-id"` | `yanproxy/anthropic/claude-opus-4-8` |
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 response TPS, and session cost |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-editor-shell",
3
- "version": "0.9.0",
3
+ "version": "0.9.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/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 completed response throughput. Timing begins with the first streamed
288
- // content delta, excluding time-to-first-token; it is intentionally not
289
- // reconstructed for resumed sessions because persisted messages have no end time.
290
- let _streamStartedAt: number | undefined;
291
- let _pendingResponseElapsedMs: number | undefined;
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
- _streamStartedAt = undefined;
303
- _pendingResponseElapsedMs = undefined;
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
- _streamStartedAt == null &&
310
- (t === "thinking_delta" || t === "text_delta" || t === "toolcall_delta")
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
- _streamStartedAt = undefined;
338
- _pendingResponseElapsedMs = undefined;
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 output = (event.message as MsgSnap).usage?.output ?? 0;
358
- const elapsedMs = _pendingResponseElapsedMs ?? 0;
359
- _latestTps = elapsedMs > 0 && output > 0 ? output / (elapsedMs / 1000) : undefined;
360
- _pendingResponseElapsedMs = undefined;
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
- _streamStartedAt = undefined;
375
- _pendingResponseElapsedMs = undefined;
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("success", `${_latestTps.toFixed(1)} t/s`)}`
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 response TPS: ${_latestTps != null ? `${_latestTps.toFixed(1)} t/s` : "n/a"}`);
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)}`);
@@ -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
+ }