@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 CHANGED
@@ -1,11 +1,13 @@
1
1
  # pi-editor-shell
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@d3ara1n/pi-editor-shell)](https://www.npmjs.com/package/@d3ara1n/pi-editor-shell) [![npm downloads](https://img.shields.io/npm/dm/@d3ara1n/pi-editor-shell)](https://www.npmjs.com/package/@d3ara1n/pi-editor-shell) [![license](https://img.shields.io/npm/l/@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 (Yanproxy)` |
56
- | `"provider-id"` | `yanproxy/anthropic/claude-opus-4-8` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-editor-shell",
3
- "version": "0.8.3",
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
@@ -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, pinned config, cache totals",
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)}`);
@@ -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
+ }