@d3ara1n/pi-editor-shell 0.8.3 → 0.9.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 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 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`.
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
 
@@ -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 response 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.0",
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
@@ -74,8 +74,12 @@ const DEFAULT_ICONS: EditorShellIcons = {
74
74
  * full pi-ai message union tree. */
75
75
  interface UsageSnap {
76
76
  input?: number;
77
+ output?: number;
77
78
  cacheRead?: number;
78
79
  cacheWrite?: number;
80
+ cost?: {
81
+ total?: number;
82
+ };
79
83
  }
80
84
  interface MsgSnap {
81
85
  role: string;
@@ -84,6 +88,7 @@ interface MsgSnap {
84
88
  interface EntrySnap {
85
89
  type: string;
86
90
  message?: MsgSnap;
91
+ usage?: UsageSnap;
87
92
  }
88
93
 
89
94
  /** Sum cache-related usage across all assistant messages on the session.
@@ -102,6 +107,25 @@ function sumSessionUsage(ctx: { sessionManager: { getEntries(): unknown[] } }):
102
107
  return { input, cacheRead, cacheWrite };
103
108
  }
104
109
 
110
+ /** Sum billable usage across the full session, matching pi's built-in footer:
111
+ * assistant responses, usage-bearing tool results, compactions, and branch
112
+ * summaries. Providers without configured pricing report zero cost. */
113
+ function sumSessionCost(ctx: { sessionManager: { getEntries(): unknown[] } }): number {
114
+ let total = 0;
115
+ for (const entry of ctx.sessionManager.getEntries()) {
116
+ const e = entry as EntrySnap;
117
+ let usage: UsageSnap | undefined;
118
+ if (e.type === "message" && (e.message?.role === "assistant" || e.message?.role === "toolResult")) {
119
+ usage = e.message.usage;
120
+ } else if (e.type === "compaction" || e.type === "branch_summary") {
121
+ usage = e.usage;
122
+ }
123
+ const cost = usage?.cost?.total;
124
+ if (typeof cost === "number" && Number.isFinite(cost)) total += cost;
125
+ }
126
+ return total;
127
+ }
128
+
105
129
  /** Sum cache-read tokens across all assistant messages — session total for
106
130
  * the "(14.0M)" display in the border. Kept separate from sumSessionUsage
107
131
  * to keep the hot path (agent_end) minimal. */
@@ -260,13 +284,33 @@ export default function (pi: ExtensionAPI) {
260
284
  // entries every frame.
261
285
  let _cacheTotal = 0;
262
286
  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;
292
+ let _latestTps: number | undefined;
293
+ // Full-session billable cost, including tool/summarization usage. Zero means the
294
+ // provider supplied no priced usage, so the border omits the dollar segment.
295
+ let _sessionCost = 0;
263
296
 
264
297
  // ── Phase-aware spinner + lifecycle ────────────────────────────
265
298
  // Each event asks the editor for a phase; CardEditor.setSpinner is itself
266
299
  // a same-phase no-op, so rapid event streams never reset the animation.
300
+ pi.on("message_start", (event) => {
301
+ if (event.message.role !== "assistant") return;
302
+ _streamStartedAt = undefined;
303
+ _pendingResponseElapsedMs = undefined;
304
+ });
267
305
  pi.on("turn_start", () => editor?.setSpinner("thinking"));
268
306
  pi.on("message_update", (event) => {
269
307
  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();
313
+ }
270
314
  let next: SpinnerPhase;
271
315
  if (t.startsWith("thinking_")) next = "thinking";
272
316
  else if (t.startsWith("text_")) next = "outputting";
@@ -274,22 +318,48 @@ export default function (pi: ExtensionAPI) {
274
318
  else return;
275
319
  editor?.setSpinner(next);
276
320
  });
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
+ });
277
327
  pi.on("tool_execution_start", () => editor?.setSpinner("exec"));
278
328
  pi.on("agent_end", (_event, ctx) => {
279
329
  // cacheRead totals + latest usage are stable once a turn finishes —
280
330
  // recompute here instead of on every render frame.
281
331
  _cacheTotal = sumCacheRead(ctx);
282
332
  _latestUsage = latestAssistantUsage(ctx);
333
+ _sessionCost = sumSessionCost(ctx);
283
334
  editor?.setSpinner(null);
284
335
  });
285
336
  pi.on("session_shutdown", () => {
337
+ _streamStartedAt = undefined;
338
+ _pendingResponseElapsedMs = undefined;
339
+ _latestTps = undefined;
340
+ _sessionCost = 0;
286
341
  editor?.setSpinner(null);
287
342
  editor = undefined;
288
343
  });
344
+ pi.on("session_compact", (_event, ctx) => {
345
+ _sessionCost = sumSessionCost(ctx);
346
+ editor?.requestRender();
347
+ });
348
+ pi.on("session_tree", (_event, ctx) => {
349
+ _sessionCost = sumSessionCost(ctx);
350
+ editor?.requestRender();
351
+ });
289
352
 
290
353
  // Refresh git dirty after every agent turn (tools may have changed files).
291
354
  // Async — never blocks the event loop; re-renders once settled.
292
- pi.on("turn_end", () => {
355
+ pi.on("turn_end", (event) => {
356
+ 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;
361
+ editor?.requestRender();
362
+ }
293
363
  if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
294
364
  });
295
365
 
@@ -301,6 +371,10 @@ export default function (pi: ExtensionAPI) {
301
371
  icons = { ...DEFAULT_ICONS, ...config.icons };
302
372
  _cacheTotal = sumCacheRead(ctx);
303
373
  _latestUsage = latestAssistantUsage(ctx);
374
+ _streamStartedAt = undefined;
375
+ _pendingResponseElapsedMs = undefined;
376
+ _latestTps = undefined;
377
+ _sessionCost = sumSessionCost(ctx);
304
378
  refreshGitDirty(ctx.cwd, () => editor?.requestRender());
305
379
 
306
380
  // Fresh segments on every render — reads live ctx state, so thinking /
@@ -348,6 +422,14 @@ export default function (pi: ExtensionAPI) {
348
422
  _cacheTotal > 0
349
423
  ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${icons.cache} ${formatTokens(cacheReadNow)} (${formatTokens(_cacheTotal)})${hitRate != null ? ` ${icons.hitRate} ${hitRate.toFixed(1)}%` : ""}`)}`
350
424
  : "";
425
+ const tpsPart =
426
+ _latestTps != null
427
+ ? `${theme.fg("dim", " · ")}${theme.fg("success", `${_latestTps.toFixed(1)} t/s`)}`
428
+ : "";
429
+ const costPart =
430
+ _sessionCost > 0
431
+ ? `${theme.fg("dim", " · ")}${theme.fg("warning", `$${_sessionCost.toFixed(3)}`)}`
432
+ : "";
351
433
 
352
434
  // Git branch + dirty state — pi's format: ~/Projects (main).
353
435
  const cwdText = formatCwd(ctx.cwd);
@@ -364,7 +446,7 @@ export default function (pi: ExtensionAPI) {
364
446
  topLeft: ` ${theme.fg("accent", `${icons.model} ${model}`)}${theme.fg("dim", " · ")}${theme.fg(thinkingColor, `${icons.thinking} ${thinking}`)} `,
365
447
  topRight: buildPinned(),
366
448
  // Context in severity color; cwd stays muted so it never competes.
367
- bottomLeft: ` ${theme.fg(contextToken(pct), `${icons.context} ${ctxText}`)}${cachePart} `,
449
+ bottomLeft: ` ${theme.fg(contextToken(pct), `${icons.context} ${ctxText}`)}${cachePart}${tpsPart}${costPart} `,
368
450
  bottomRight: theme.fg("muted", ` ${cwdDisplay} `),
369
451
  };
370
452
  };
@@ -418,7 +500,7 @@ export default function (pi: ExtensionAPI) {
418
500
 
419
501
  // ── Debug command ──────────────────────────────────────────────
420
502
  pi.registerCommand("editor-shell:status", {
421
- description: "Show editor-shell debug state: status keys, pinned config, cache totals",
503
+ description: "Show editor-shell debug state: status keys, performance, and usage totals",
422
504
  handler: async (_args, ctx) => {
423
505
  // Refresh git dirty first so the status output reflects the current
424
506
  // working tree — the event-driven cache is otherwise only updated at
@@ -459,6 +541,10 @@ export default function (pi: ExtensionAPI) {
459
541
  lines.push(` session cacheWrite: ${formatTokens(sess.cacheWrite ?? 0)}`);
460
542
  const sRate = cacheHitRate(sess);
461
543
  lines.push(` session hit rate: ${sRate != null ? `${sRate.toFixed(1)}%` : "n/a"}`);
544
+ const sessionCost = sumSessionCost(ctx);
545
+ _sessionCost = sessionCost;
546
+ 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"}`);
462
548
  const latest = latestAssistantUsage(ctx);
463
549
  const now = latest?.cacheRead ?? 0;
464
550
  lines.push(` this turn cacheRead: ${formatTokens(now)}`);