@d3ara1n/pi-editor-shell 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-editor-shell",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Replaces pi's default editor and status bar with a unified rounded-corner shell — no Nerd Font required",
6
6
  "keywords": [
@@ -58,24 +58,16 @@ export interface FrameSegments {
58
58
  bottomRight: string;
59
59
  }
60
60
 
61
- /** Fresh, already-themed segments. The frame color itself comes from pi's
62
- * editor.borderColor field (same source as the default editor), kept in
63
- * sync by pi on thinking-level / bash-mode changes — so the border
64
- * matches the built-in behavior.
65
- *
66
- * (An optional `frame` override exists for experiments; when omitted the
67
- * editor falls back to editor.borderColor.) */
68
- export interface Frame {
69
- segments: FrameSegments;
70
- frame?: (s: string) => string;
71
- }
72
-
73
- /** Returns a fresh Frame on each render call. */
74
- export type FrameProvider = () => Frame;
61
+ /** Fresh, already-themed segments on each render call. The frame color
62
+ * itself always follows `this.borderColor` (pi keeps it in sync with the
63
+ * thinking level / bash mode), so the provider only supplies text. */
64
+ export type FrameProvider = () => FrameSegments;
75
65
 
76
- const EMPTY_FRAME: Frame = {
77
- segments: { topLeft: "", topRight: "", bottomLeft: "", bottomRight: "" },
78
- frame: (s) => s,
66
+ const EMPTY_SEGMENTS: FrameSegments = {
67
+ topLeft: "",
68
+ topRight: "",
69
+ bottomLeft: "",
70
+ bottomRight: "",
79
71
  };
80
72
 
81
73
  /** Strip ANSI SGR escapes and pi's zero-width cursor marker so a line can
@@ -122,15 +114,25 @@ function fitFrameRow(
122
114
  const minGap = 3;
123
115
  let l = leftText;
124
116
  let r = rightText;
117
+ let lw = visibleWidth(l);
118
+ let rw = visibleWidth(r);
125
119
 
126
- while (visibleWidth(l) + visibleWidth(r) + minGap > inner && visibleWidth(r) > 0) {
127
- r = truncateToWidth(r, Math.max(0, visibleWidth(r) - 1), "");
128
- }
129
- while (visibleWidth(l) + visibleWidth(r) + minGap > inner && visibleWidth(l) > 0) {
130
- l = truncateToWidth(l, Math.max(0, visibleWidth(l) - 1), "");
120
+ // Shrink right first, then left, so a minimum gap always survives
121
+ // but at most one truncate per side instead of a per-character loop.
122
+ if (lw + rw + minGap > inner) {
123
+ const maxR = Math.max(0, inner - lw - minGap);
124
+ if (rw > maxR) {
125
+ r = truncateToWidth(r, maxR, "");
126
+ rw = visibleWidth(r);
127
+ }
128
+ const maxL = Math.max(0, inner - rw - minGap);
129
+ if (lw > maxL) {
130
+ l = truncateToWidth(l, maxL, "");
131
+ lw = visibleWidth(l);
132
+ }
131
133
  }
132
134
 
133
- const gap = Math.max(0, inner - visibleWidth(l) - visibleWidth(r));
135
+ const gap = Math.max(0, inner - lw - rw);
134
136
  return `${border(leftCap)}${l}${border("─".repeat(gap))}${r}${border(rightCap)}`;
135
137
  }
136
138
 
@@ -172,11 +174,15 @@ export class CardEditor extends CustomEditor {
172
174
  this.spinnerPhase = phase;
173
175
  if (phase) {
174
176
  this.spinnerIdx = 0;
177
+ // One timer serves all phases: the callback re-reads spinnerPhase each
178
+ // tick so a thinking→outputting transition swaps frames without
179
+ // rebuilding the interval. The guard keeps TS happy; in practice phase
180
+ // is always set while the timer is live (cleared the instant it nulls).
175
181
  if (!this.spinnerTimer) {
176
182
  this.spinnerTimer = setInterval(() => {
177
- const frames = this.spinnerPhase ? SPINNERS[this.spinnerPhase] : null;
178
- if (!frames) return;
179
- this.spinnerIdx = (this.spinnerIdx + 1) % frames.length;
183
+ const phase = this.spinnerPhase;
184
+ if (!phase) return;
185
+ this.spinnerIdx = (this.spinnerIdx + 1) % SPINNERS[phase].length;
180
186
  this.tui.requestRender();
181
187
  }, SPINNER_INTERVAL_MS);
182
188
  }
@@ -187,6 +193,13 @@ export class CardEditor extends CustomEditor {
187
193
  this.tui.requestRender();
188
194
  }
189
195
 
196
+ /** Request a re-render after external async state changes (e.g. git dirty
197
+ * settling). The Editor's own TUI handle is protected, so callers outside
198
+ * the class — like the extension's event handlers — go through here. */
199
+ requestRender(): void {
200
+ this.tui.requestRender();
201
+ }
202
+
190
203
  render(width: number): string[] {
191
204
  // Too narrow — delegate to the default rendering, untouched.
192
205
  if (width < MIN_WIDTH) {
@@ -198,17 +211,17 @@ export class CardEditor extends CustomEditor {
198
211
  const inner = super.render(width - 2);
199
212
  if (inner.length === 0) return inner;
200
213
 
201
- // Border color: prefer the provider's override when given, otherwise
202
- // fall back to pi's editor.borderColor (same field the default editor
203
- // reads, updated by pi on thinking / bash changes).
204
- const frameObj = this.frameProvider?.() ?? EMPTY_FRAME;
205
- const border = frameObj.frame ?? this.borderColor;
206
- const seg = frameObj.segments;
214
+ // Border color always follows this.borderColor pi mutates it to encode
215
+ // thinking level / bash mode (same field the default editor reads), so
216
+ // the frame retints in lockstep with no extra wiring.
217
+ const border = this.borderColor;
218
+ const seg = this.frameProvider?.() ?? EMPTY_SEGMENTS;
207
219
  // While the agent is active, the current phase spinner replaces the
208
220
  // model text in the top-left slot — a moving indicator reads as "busy"
209
- // more strongly than a static label.
221
+ // more strongly than a static label. spinnerIdx is kept in range by
222
+ // setSpinner's timer, so no modulo is needed here.
210
223
  const topLeft = this.spinnerPhase
211
- ? `${RESET}${border(` ${SPINNERS[this.spinnerPhase][this.spinnerIdx % SPINNERS[this.spinnerPhase].length]} `)}${seg.topLeft.trimStart()}`
224
+ ? `${RESET}${border(` ${SPINNERS[this.spinnerPhase][this.spinnerIdx]} `)}${seg.topLeft.trimStart()}`
212
225
  : seg.topLeft;
213
226
 
214
227
  // The default Editor appends autocomplete rows *after* the bottom border.
@@ -241,25 +254,10 @@ export class CardEditor extends CustomEditor {
241
254
  // ctx/cwd always sit just under the editor. With a popup below, this
242
255
  // border becomes a T-junction divider; without one it's the rounded
243
256
  // bottom of the card.
244
- out.push(
245
- hasPopup
246
- ? fitFrameRow(
247
- GLYPH.divLeft,
248
- GLYPH.divRight,
249
- seg.bottomLeft,
250
- seg.bottomRight,
251
- width,
252
- border,
253
- )
254
- : fitFrameRow(
255
- GLYPH.bottomLeft,
256
- GLYPH.bottomRight,
257
- seg.bottomLeft,
258
- seg.bottomRight,
259
- width,
260
- border,
261
- ),
262
- );
257
+ const [lc, rc] = hasPopup
258
+ ? [GLYPH.divLeft, GLYPH.divRight]
259
+ : [GLYPH.bottomLeft, GLYPH.bottomRight];
260
+ out.push(fitFrameRow(lc, rc, seg.bottomLeft, seg.bottomRight, width, border));
263
261
  } else {
264
262
  // Content row and popup items alike live inside the card. Reset around
265
263
  // the text so its styling never leaks into the frame and vice versa.
package/src/config.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Read editor-shell configuration from settings files.
3
3
  *
4
- * Global (~/.pi/agent/settings.json) + project (.pi/settings.json),
5
- * project overrides global.
4
+ * Global (~/.pi/agent/settings.json) + project (.pi/settings.json).
5
+ * Project settings, when present, override global wholesale — standard
6
+ * "project wins" design, no field-level merging.
6
7
  */
7
8
 
8
9
  import * as fs from "node:fs";
@@ -27,45 +28,32 @@ function getAgentDir(): string {
27
28
  return path.join(os.homedir(), ".pi", "agent");
28
29
  }
29
30
 
30
- function readSettingsFile(filePath: string): any {
31
+ /** Read the `editorShell` block from a settings file.
32
+ * Returns undefined on missing file / parse error / non-object value —
33
+ * pi surfaces its own settings errors, this loader stays lenient. */
34
+ function readEditorShell(filePath: string): Record<string, unknown> | undefined {
31
35
  try {
32
- if (!fs.existsSync(filePath)) return {};
33
- const content = fs.readFileSync(filePath, "utf-8");
34
- return JSON.parse(content);
36
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"))?.editorShell;
37
+ return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : undefined;
35
38
  } catch {
36
- return {};
39
+ return undefined;
37
40
  }
38
41
  }
39
42
 
40
- function merge(target: any, source: any): any {
41
- if (!source || typeof source !== "object") return target;
42
- if (!target || typeof target !== "object") return source;
43
- const result = { ...target };
44
- for (const key of Object.keys(source)) {
45
- if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
46
- result[key] = merge(result[key], source[key]);
47
- } else {
48
- result[key] = source[key];
49
- }
50
- }
51
- return result;
52
- }
53
-
54
43
  /**
55
- * Load editorShell config from merged settings.
44
+ * Load editorShell config. Project overrides global wholesale.
56
45
  * @param cwd - Project working directory
57
46
  */
58
47
  export function loadEditorShellConfig(cwd?: string): EditorShellConfig {
59
- const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
60
- const projectSettings = cwd ? readSettingsFile(path.join(cwd, ".pi", "settings.json")) : {};
61
- const settings = merge(globalSettings, projectSettings);
62
-
63
- const raw = settings?.editorShell;
48
+ const globalRaw = readEditorShell(path.join(getAgentDir(), "settings.json"));
49
+ const projectRaw = cwd ? readEditorShell(path.join(cwd, ".pi", "settings.json")) : undefined;
50
+ const raw = projectRaw ?? globalRaw;
64
51
  if (!raw) return { ...DEFAULT_CONFIG };
65
52
 
53
+ const pinned = raw.pinnedStatus;
66
54
  return {
67
- pinnedStatus: Array.isArray(raw.pinnedStatus)
68
- ? raw.pinnedStatus.filter((k: any) => typeof k === "string")
55
+ pinnedStatus: Array.isArray(pinned)
56
+ ? pinned.filter((k): k is string => typeof k === "string")
69
57
  : DEFAULT_CONFIG.pinnedStatus,
70
58
  };
71
59
  }
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { visibleWidth } from "@earendil-works/pi-tui";
3
- import { spawnSync } from "node:child_process";
3
+ import { spawn } from "node:child_process";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
4
6
  import { CardEditor, type FrameProvider, type SpinnerPhase } from "./card-editor";
5
7
  import { loadEditorShellConfig, type EditorShellConfig } from "./config";
6
8
 
@@ -15,10 +17,14 @@ import { loadEditorShellConfig, type EditorShellConfig } from "./config";
15
17
  * rainbow-editor, modal-editor, …). Disable those when enabling this one.
16
18
  */
17
19
 
18
- /** Collapse $HOME to `~` for display. */
20
+ /** Collapse the user's home directory to `~` for display.
21
+ * Uses os.homedir() + path.sep so it works across platforms and does not
22
+ * match sibling dirs that merely share a string prefix with home. */
19
23
  function formatCwd(cwd: string): string {
20
- const home = process.env.HOME;
21
- if (home && cwd.startsWith(home)) return `~${cwd.slice(home.length)}`;
24
+ const home = os.homedir();
25
+ if (!home) return cwd;
26
+ if (cwd === home) return "~";
27
+ if (cwd.startsWith(home + path.sep)) return `~${cwd.slice(home.length)}`;
22
28
  return cwd;
23
29
  }
24
30
 
@@ -35,7 +41,7 @@ const THINKING_TOKEN: Record<string, ThemeColor> = {
35
41
 
36
42
  /** Context-fill severity by usage ratio — green / amber / red. */
37
43
  function contextToken(pct: number | null | undefined): ThemeColor {
38
- if (pct === null || pct === undefined) return "muted";
44
+ if (pct == null) return "muted";
39
45
  if (pct >= 80) return "error";
40
46
  if (pct >= 50) return "warning";
41
47
  return "success";
@@ -43,28 +49,32 @@ function contextToken(pct: number | null | undefined): ThemeColor {
43
49
 
44
50
  // ── Nerd Font icons (Octicons + FontAwesome) ──────────────────────────
45
51
  const ICON = {
46
- model: "\uf4bc", //oct-cpu
47
- thinking: "\uf400", //oct-light-bulb
48
- context: "\uf49b", //oct-cache
52
+ model: "\uf4bc", // oct-cpu
53
+ thinking: "\uf400", // oct-light-bulb
54
+ context: "\uf49b", // oct-cache
49
55
  cache: "\u26a1", // ⚡ oct-zap
50
- folder: "\uf07c", //fa-folder
56
+ hitRate: "\uf140", // fa-bullseye(靶心,缓存命中率)
57
+ folder: "\uf07c", // fa-folder
51
58
  } as const;
52
59
 
53
- /** Sum cache-read tokens across all assistant messages same source and
54
- * same accumulation as pi's own footer (which renders "R14M").
55
- * Returns null when there is nothing to measure.
56
- * Inline types avoid importing the full pi-ai message union tree. */
60
+ /** Minimal inline types to read cache-read totals without importing the
61
+ * full pi-ai message union tree. */
62
+ interface UsageSnap {
63
+ input?: number;
64
+ cacheRead?: number;
65
+ cacheWrite?: number;
66
+ }
57
67
  interface MsgSnap {
58
68
  role: string;
59
- usage?: { cacheRead?: number };
69
+ usage?: UsageSnap;
60
70
  }
61
71
  interface EntrySnap {
62
72
  type: string;
63
73
  message?: MsgSnap;
64
74
  }
65
75
 
66
- /** Sum cache-read tokens across all assistant messages,
67
- * matching pi's own footer filtering (type === "message"). */
76
+ /** Sum cache-read tokens across all assistant messages, matching pi's own
77
+ * footer filtering (type === "message") and accumulation ("R14M"). */
68
78
  function sumCacheRead(ctx: { sessionManager: { getEntries(): unknown[] } }): number {
69
79
  let total = 0;
70
80
  for (const entry of ctx.sessionManager.getEntries()) {
@@ -75,6 +85,28 @@ function sumCacheRead(ctx: { sessionManager: { getEntries(): unknown[] } }): num
75
85
  return total;
76
86
  }
77
87
 
88
+ /** Usage of the most recent assistant message — drives the per-turn
89
+ * cacheRead and the hit rate, matching pi's footer (last entry wins). */
90
+ function latestAssistantUsage(ctx: { sessionManager: { getEntries(): unknown[] } }): UsageSnap | undefined {
91
+ let latest: UsageSnap | undefined;
92
+ for (const entry of ctx.sessionManager.getEntries()) {
93
+ const e = entry as EntrySnap;
94
+ if (e.type !== "message" || e.message?.role !== "assistant" || !e.message.usage) continue;
95
+ latest = e.message.usage;
96
+ }
97
+ return latest;
98
+ }
99
+
100
+ /** Cache hit rate for a single turn: cacheRead / (input + cacheRead +
101
+ * cacheWrite) × 100 — same formula pi's footer uses for "CHxx%".
102
+ * Returns undefined when there's no usage or no prompt tokens. */
103
+ function cacheHitRate(u: UsageSnap | undefined): number | undefined {
104
+ if (!u) return undefined;
105
+ const prompt = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
106
+ if (prompt <= 0) return undefined;
107
+ return ((u.cacheRead ?? 0) / prompt) * 100;
108
+ }
109
+
78
110
  /** Format a token count for display: 14000000 → "14.0M", 132000 → "132.0k". */
79
111
  function formatTokens(n: number): string {
80
112
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
@@ -90,34 +122,57 @@ interface GitDirty {
90
122
  }
91
123
  let _gitDirty: GitDirty | undefined;
92
124
 
93
- /** Run `git status --porcelain` and count staged / unstaged files. */
94
- function refreshGitDirty(cwd: string): void {
95
- try {
96
- const r = spawnSync(
97
- "git",
98
- ["--no-optional-locks", "status", "--porcelain"],
99
- { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
100
- );
101
- if (r.status !== 0 || r.error) { _gitDirty = undefined; return; }
102
- const lines = r.stdout.trim();
103
- if (!lines) { _gitDirty = { staged: 0, unstaged: 0 }; return; }
104
-
105
- let staged = 0;
106
- let unstaged = 0;
107
- for (const line of lines.split("\n")) {
108
- if (line.length < 2) continue;
109
- const x = line[0];
110
- const y = line[1];
111
- if (x !== " " && x !== "?" && x !== "!") staged++;
112
- if (y !== " ") unstaged++;
113
- }
114
- _gitDirty = { staged, unstaged };
115
- } catch {
116
- _gitDirty = undefined;
125
+ /** Parse `git status --porcelain` output into staged / unstaged counts. */
126
+ function parseGitPorcelain(stdout: string): GitDirty {
127
+ const lines = stdout.trim();
128
+ if (!lines) return { staged: 0, unstaged: 0 };
129
+
130
+ let staged = 0;
131
+ let unstaged = 0;
132
+ for (const line of lines.split("\n")) {
133
+ if (line.length < 2) continue;
134
+ const x = line[0];
135
+ const y = line[1];
136
+ if (x !== " " && x !== "?" && x !== "!") staged++;
137
+ if (y !== " ") unstaged++;
117
138
  }
139
+ return { staged, unstaged };
140
+ }
141
+
142
+ /** Run `git status --porcelain` asynchronously so a slow / hanging git never
143
+ * blocks the event loop (turn_end is the most latency-sensitive moment —
144
+ * the agent just finished and the user wants to type). Updates `_gitDirty`
145
+ * and invokes `onDone` once settled so the caller can trigger a re-render.
146
+ * A 2s guard kills a runaway process. */
147
+ function refreshGitDirty(cwd: string, onDone?: () => void): void {
148
+ const child = spawn(
149
+ "git",
150
+ ["--no-optional-locks", "status", "--porcelain"],
151
+ { cwd, stdio: ["ignore", "pipe", "ignore"] },
152
+ );
153
+ let stdout = "";
154
+ const timer = setTimeout(() => child.kill("SIGTERM"), 2000);
155
+
156
+ // spawn emits both 'error' (e.g. git missing → ENOENT) and a subsequent
157
+ // 'close'; guard so onDone fires exactly once.
158
+ let done = false;
159
+ const settle = (ok: boolean, out: string): void => {
160
+ if (done) return;
161
+ done = true;
162
+ clearTimeout(timer);
163
+ _gitDirty = ok ? parseGitPorcelain(out) : undefined;
164
+ onDone?.();
165
+ };
166
+
167
+ child.stdout?.on("data", (chunk: Buffer | string) => {
168
+ stdout += chunk;
169
+ });
170
+ child.on("error", () => settle(false, ""));
171
+ child.on("close", (code) => settle(code === 0, stdout));
118
172
  }
119
173
 
120
- /** Format dirty state as pi-style "+2 ~1" string, or "" if clean / unknown. */
174
+ /** Format dirty state as pi-style "+2 ~1" string (leading space), or "" if
175
+ * clean / unknown — ready to splice into a "(branch…)" segment. */
121
176
  function gitDirtyDisplay(): string {
122
177
  if (!_gitDirty) return "";
123
178
  const parts: string[] = [];
@@ -143,17 +198,16 @@ export default function (pi: ExtensionAPI) {
143
198
  let footerSnap: FooterSnap | undefined;
144
199
  // CWD cached from session_start — used by turn_end to refresh git dirty.
145
200
  let _cwd = "";
146
-
147
- // ── Phase-aware spinner ────────────────────────────────────────
148
- // Track which sub-phase the agent is in so we can pick the right
149
- // spinner animation: thinking (●/○), outputting (sand-pile),
150
- // toolcall drafting (arc rotation), tool execution (progress bar).
151
- let _phase: SpinnerPhase | null = null;
152
-
153
- pi.on("turn_start", () => {
154
- _phase = "thinking";
155
- editor?.setSpinner("thinking");
156
- });
201
+ // cacheRead total + latest-turn usage, refreshed at session_start +
202
+ // agent_end. The render provider reads these instead of re-scanning
203
+ // entries every frame.
204
+ let _cacheTotal = 0;
205
+ let _latestUsage: UsageSnap | undefined;
206
+
207
+ // ── Phase-aware spinner + lifecycle ────────────────────────────
208
+ // Each event asks the editor for a phase; CardEditor.setSpinner is itself
209
+ // a same-phase no-op, so rapid event streams never reset the animation.
210
+ pi.on("turn_start", () => editor?.setSpinner("thinking"));
157
211
  pi.on("message_update", (event) => {
158
212
  const t = event.assistantMessageEvent.type;
159
213
  let next: SpinnerPhase;
@@ -161,77 +215,25 @@ export default function (pi: ExtensionAPI) {
161
215
  else if (t.startsWith("text_")) next = "outputting";
162
216
  else if (t.startsWith("toolcall_")) next = "toolcall";
163
217
  else return;
164
- if (next !== _phase) {
165
- _phase = next;
166
- editor?.setSpinner(next);
167
- }
218
+ editor?.setSpinner(next);
168
219
  });
169
- pi.on("tool_execution_start", () => {
170
- _phase = "exec";
171
- editor?.setSpinner("exec");
172
- });
173
- pi.on("agent_end", () => {
174
- _phase = null;
220
+ pi.on("tool_execution_start", () => editor?.setSpinner("exec"));
221
+ pi.on("agent_end", (_event, ctx) => {
222
+ // cacheRead totals + latest usage are stable once a turn finishes —
223
+ // recompute here instead of on every render frame.
224
+ _cacheTotal = sumCacheRead(ctx);
225
+ _latestUsage = latestAssistantUsage(ctx);
175
226
  editor?.setSpinner(null);
176
227
  });
177
228
  pi.on("session_shutdown", () => {
178
- _phase = null;
179
229
  editor?.setSpinner(null);
180
230
  editor = undefined;
181
231
  });
182
232
 
183
- // ── Debug command ──────────────────────────────────────────────
184
- pi.registerCommand("editor-shell:status", {
185
- description: "Show editor-shell debug state: status keys, pinned config, cache totals",
186
- handler: async (_args, ctx) => {
187
- const lines: string[] = [];
188
-
189
- lines.push("[editor-shell config]");
190
- lines.push(` pinnedStatus: [${config.pinnedStatus.join(", ")}]`);
191
-
192
- lines.push("");
193
- lines.push("[extension statuses]");
194
- if (footerSnap) {
195
- const entries = Array.from(footerSnap.getExtensionStatuses().entries());
196
- if (entries.length === 0) {
197
- lines.push(" (none)");
198
- } else {
199
- const pinned = new Set(config.pinnedStatus);
200
- for (const [key, text] of entries.sort(([a], [b]) => a.localeCompare(b))) {
201
- const mark = pinned.has(key) ? " ← pinned" : "";
202
- lines.push(` ${key}: ${text}${mark}`);
203
- }
204
- }
205
- } else {
206
- lines.push(" (footer not initialized)");
207
- }
208
-
209
- lines.push("");
210
- lines.push("[cache totals]");
211
- const tokens = sumCacheRead(ctx);
212
- lines.push(` cacheRead: ${tokens > 0 ? formatTokens(tokens) : "0"}`);
213
-
214
- lines.push("");
215
- const cwd = ctx.cwd;
216
- lines.push(`[context] cwd: ${cwd}`);
217
- const branch = footerSnap?.getGitBranch();
218
- lines.push(` git branch: ${branch ?? "(not in repo)"}`);
219
- if (branch) {
220
- const ds = _gitDirty
221
- ? `+${_gitDirty.staged} ~${_gitDirty.unstaged}`
222
- : "(clean)";
223
- lines.push(` git dirty: ${ds}`);
224
- }
225
- const m = ctx.model;
226
- lines.push(` model: ${m ? `${m.provider}/${m.id}` : "none"}`);
227
-
228
- ctx.ui.notify(lines.join("\n"), "info");
229
- },
230
- });
231
-
232
233
  // Refresh git dirty after every agent turn (tools may have changed files).
234
+ // Async — never blocks the event loop; re-renders once settled.
233
235
  pi.on("turn_end", () => {
234
- if (_cwd) refreshGitDirty(_cwd);
236
+ if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
235
237
  });
236
238
 
237
239
  pi.on("session_start", (_event, ctx) => {
@@ -239,7 +241,9 @@ export default function (pi: ExtensionAPI) {
239
241
 
240
242
  _cwd = ctx.cwd;
241
243
  config = loadEditorShellConfig(ctx.cwd);
242
- refreshGitDirty(ctx.cwd);
244
+ _cacheTotal = sumCacheRead(ctx);
245
+ _latestUsage = latestAssistantUsage(ctx);
246
+ refreshGitDirty(ctx.cwd, () => editor?.requestRender());
243
247
 
244
248
  // Fresh segments on every render — reads live ctx state, so thinking /
245
249
  // context % updates show up on the next paint without extra wiring.
@@ -247,6 +251,19 @@ export default function (pi: ExtensionAPI) {
247
251
  // the default editor's behavior.
248
252
  const provider: FrameProvider = () => {
249
253
  const theme = ctx.ui.theme;
254
+
255
+ // Resolve pinned status keys → already-themed text, " · "-joined.
256
+ const buildPinned = (): string => {
257
+ const keys = config.pinnedStatus;
258
+ if (keys.length === 0 || !footerSnap) return "";
259
+ const all = footerSnap.getExtensionStatuses();
260
+ const texts = keys
261
+ .map((k) => all.get(k))
262
+ .filter((s): s is string => s != null);
263
+ if (texts.length === 0) return "";
264
+ return ` ${texts.map((s) => theme.fg("muted", s)).join(theme.fg("dim", " · "))} `;
265
+ };
266
+
250
267
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "no model";
251
268
  const thinking = pi.getThinkingLevel();
252
269
  const thinkingColor = THINKING_TOKEN[thinking] ?? "muted";
@@ -255,15 +272,18 @@ export default function (pi: ExtensionAPI) {
255
272
  const pct = usage?.percent;
256
273
  const ctxWindow = usage?.contextWindow ?? ctx.model?.contextWindow;
257
274
  const ctxText =
258
- pct !== null && pct !== undefined && ctxWindow
259
- ? `${Math.round(pct)}%/${(ctxWindow / 1000).toFixed(0)}k`
275
+ pct != null && ctxWindow
276
+ ? `${pct.toFixed(1)}%/${(ctxWindow / 1000).toFixed(0)}k`
260
277
  : "?/??k";
261
278
 
262
- // Cache-read token total same data source pi's own footer uses for "R14M".
263
- const cacheTokens = sumCacheRead(ctx);
279
+ // Cache-read tokensper-turn figure first, session total in parens,
280
+ // then hit rate (pi's "CHxx%" formula). All refreshed at agent_end and
281
+ // read from cache off the hot path.
282
+ const cacheReadNow = _latestUsage?.cacheRead ?? 0;
283
+ const hitRate = cacheHitRate(_latestUsage);
264
284
  const cachePart =
265
- cacheTokens > 0
266
- ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${ICON.cache} ${formatTokens(cacheTokens)}`)}`
285
+ _cacheTotal > 0
286
+ ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${ICON.cache} ${formatTokens(cacheReadNow)} (${formatTokens(_cacheTotal)})${hitRate != null ? ` ${ICON.hitRate} ${hitRate.toFixed(1)}%` : ""}`)}`
267
287
  : "";
268
288
 
269
289
  // Git branch + dirty state — pi's format: ~/Projects (main).
@@ -275,29 +295,15 @@ export default function (pi: ExtensionAPI) {
275
295
  ? `${ICON.folder} ${cwdText} (${branch}${dirty})`
276
296
  : `${ICON.folder} ${cwdText}`;
277
297
 
298
+ // Model in accent; thinking label in its level token — same hue the
299
+ // border takes on, so switching levels visibly retints both together.
278
300
  return {
279
- segments: {
280
- // Model in accent; thinking label in its level token — same hue the
281
- // border takes on, so switching levels visibly retints both together.
282
- topLeft: ` ${theme.fg("accent", `${ICON.model} ${model}`)}${theme.fg("dim", " · ")}${theme.fg(thinkingColor, `${ICON.thinking} ${thinking}`)} `,
283
- topRight: buildPinned(theme),
284
- // Context in severity color; cwd stays muted so it never competes.
285
- bottomLeft: ` ${theme.fg(contextToken(pct), `${ICON.context} ${ctxText}`)}${cachePart} `,
286
- bottomRight: theme.fg("muted", ` ${cwdDisplay} `),
287
- },
301
+ topLeft: ` ${theme.fg("accent", `${ICON.model} ${model}`)}${theme.fg("dim", " · ")}${theme.fg(thinkingColor, `${ICON.thinking} ${thinking}`)} `,
302
+ topRight: buildPinned(),
303
+ // Context in severity color; cwd stays muted so it never competes.
304
+ bottomLeft: ` ${theme.fg(contextToken(pct), `${ICON.context} ${ctxText}`)}${cachePart} `,
305
+ bottomRight: theme.fg("muted", ` ${cwdDisplay} `),
288
306
  };
289
-
290
- /** Resolve pinned status keys → already-themed text, " · "-joined. */
291
- function buildPinned(theme: typeof ctx.ui.theme): string {
292
- const keys = config.pinnedStatus;
293
- if (keys.length === 0 || !footerSnap) return "";
294
- const all = footerSnap.getExtensionStatuses();
295
- const texts = keys
296
- .map((k) => all.get(k))
297
- .filter((s): s is string => s != null);
298
- if (texts.length === 0) return "";
299
- return ` ${texts.map((s) => theme.fg("muted", s)).join(theme.fg("dim", " · "))} `;
300
- }
301
307
  };
302
308
 
303
309
  // CardEditor has its own phase-aware spinner — hide pi's built-in working loader.
@@ -342,4 +348,67 @@ export default function (pi: ExtensionAPI) {
342
348
  };
343
349
  });
344
350
  });
351
+
352
+ // ── Debug command ──────────────────────────────────────────────
353
+ pi.registerCommand("editor-shell:status", {
354
+ description: "Show editor-shell debug state: status keys, pinned config, cache totals",
355
+ handler: async (_args, ctx) => {
356
+ const lines: string[] = [];
357
+
358
+ lines.push("[editor-shell config]");
359
+ lines.push(` pinnedStatus: [${config.pinnedStatus.join(", ")}]`);
360
+
361
+ lines.push("");
362
+ lines.push("[extension statuses]");
363
+ if (footerSnap) {
364
+ const entries = Array.from(footerSnap.getExtensionStatuses().entries());
365
+ if (entries.length === 0) {
366
+ lines.push(" (none)");
367
+ } else {
368
+ const pinned = new Set(config.pinnedStatus);
369
+ for (const [key, text] of entries.sort(([a], [b]) => a.localeCompare(b))) {
370
+ // The pin marker sits after status text, whose embedded reset
371
+ // would wash it to default white — re-wrap it in dim so it stays
372
+ // consistent with the surrounding text. (status text itself
373
+ // keeps its original color by design.)
374
+ const mark = pinned.has(key) ? ctx.ui.theme.fg("dim", " ← pinned") : "";
375
+ lines.push(` ${key}: ${text}${mark}`);
376
+ }
377
+ }
378
+ } else {
379
+ lines.push(" (footer not initialized)");
380
+ }
381
+
382
+ lines.push("");
383
+ lines.push("[cache totals]");
384
+ const tokens = sumCacheRead(ctx);
385
+ lines.push(` cacheRead (session): ${tokens > 0 ? formatTokens(tokens) : "0"}`);
386
+ const latest = latestAssistantUsage(ctx);
387
+ const now = latest?.cacheRead ?? 0;
388
+ lines.push(` cacheRead (this turn): ${formatTokens(now)}`);
389
+ const hr = cacheHitRate(latest);
390
+ lines.push(` hit rate: ${hr != null ? `${hr.toFixed(1)}%` : "n/a"}`);
391
+
392
+ lines.push("");
393
+ lines.push(`[context] cwd: ${ctx.cwd}`);
394
+ const branch = footerSnap?.getGitBranch();
395
+ lines.push(` git branch: ${branch ?? "(not in repo)"}`);
396
+ if (branch) {
397
+ const dirty = gitDirtyDisplay().trim();
398
+ lines.push(` git dirty: ${dirty || "clean"}`);
399
+ }
400
+ const m = ctx.model;
401
+ lines.push(` model: ${m ? `${m.provider}/${m.id}:${pi.getThinkingLevel()}` : "none"}`);
402
+
403
+ // Wrap each line in dim explicitly. notify adds its own outer dim
404
+ // layer, but extension status text carries its own color codes that
405
+ // reset the foreground mid-message. Per-line wrapping re-asserts dim
406
+ // at the start of every line, so a status row's reset can't bleed past
407
+ // it: status stays in its original color, everything else reads dim.
408
+ ctx.ui.notify(
409
+ lines.map((l) => ctx.ui.theme.fg("dim", l)).join("\n"),
410
+ "info",
411
+ );
412
+ },
413
+ });
345
414
  }