@d3ara1n/pi-editor-shell 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-editor-shell",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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,17 +49,15 @@ 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
+ folder: "\uf07c", // fa-folder
51
57
  } as const;
52
58
 
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. */
59
+ /** Minimal inline types to read cache-read totals without importing the
60
+ * full pi-ai message union tree. */
57
61
  interface MsgSnap {
58
62
  role: string;
59
63
  usage?: { cacheRead?: number };
@@ -63,8 +67,8 @@ interface EntrySnap {
63
67
  message?: MsgSnap;
64
68
  }
65
69
 
66
- /** Sum cache-read tokens across all assistant messages,
67
- * matching pi's own footer filtering (type === "message"). */
70
+ /** Sum cache-read tokens across all assistant messages, matching pi's own
71
+ * footer filtering (type === "message") and accumulation ("R14M"). */
68
72
  function sumCacheRead(ctx: { sessionManager: { getEntries(): unknown[] } }): number {
69
73
  let total = 0;
70
74
  for (const entry of ctx.sessionManager.getEntries()) {
@@ -90,34 +94,57 @@ interface GitDirty {
90
94
  }
91
95
  let _gitDirty: GitDirty | undefined;
92
96
 
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;
97
+ /** Parse `git status --porcelain` output into staged / unstaged counts. */
98
+ function parseGitPorcelain(stdout: string): GitDirty {
99
+ const lines = stdout.trim();
100
+ if (!lines) return { staged: 0, unstaged: 0 };
101
+
102
+ let staged = 0;
103
+ let unstaged = 0;
104
+ for (const line of lines.split("\n")) {
105
+ if (line.length < 2) continue;
106
+ const x = line[0];
107
+ const y = line[1];
108
+ if (x !== " " && x !== "?" && x !== "!") staged++;
109
+ if (y !== " ") unstaged++;
117
110
  }
111
+ return { staged, unstaged };
118
112
  }
119
113
 
120
- /** Format dirty state as pi-style "+2 ~1" string, or "" if clean / unknown. */
114
+ /** Run `git status --porcelain` asynchronously so a slow / hanging git never
115
+ * blocks the event loop (turn_end is the most latency-sensitive moment —
116
+ * the agent just finished and the user wants to type). Updates `_gitDirty`
117
+ * and invokes `onDone` once settled so the caller can trigger a re-render.
118
+ * A 2s guard kills a runaway process. */
119
+ function refreshGitDirty(cwd: string, onDone?: () => void): void {
120
+ const child = spawn(
121
+ "git",
122
+ ["--no-optional-locks", "status", "--porcelain"],
123
+ { cwd, stdio: ["ignore", "pipe", "ignore"] },
124
+ );
125
+ let stdout = "";
126
+ const timer = setTimeout(() => child.kill("SIGTERM"), 2000);
127
+
128
+ // spawn emits both 'error' (e.g. git missing → ENOENT) and a subsequent
129
+ // 'close'; guard so onDone fires exactly once.
130
+ let done = false;
131
+ const settle = (ok: boolean, out: string): void => {
132
+ if (done) return;
133
+ done = true;
134
+ clearTimeout(timer);
135
+ _gitDirty = ok ? parseGitPorcelain(out) : undefined;
136
+ onDone?.();
137
+ };
138
+
139
+ child.stdout?.on("data", (chunk: Buffer | string) => {
140
+ stdout += chunk;
141
+ });
142
+ child.on("error", () => settle(false, ""));
143
+ child.on("close", (code) => settle(code === 0, stdout));
144
+ }
145
+
146
+ /** Format dirty state as pi-style "+2 ~1" string (leading space), or "" if
147
+ * clean / unknown — ready to splice into a "(branch…)" segment. */
121
148
  function gitDirtyDisplay(): string {
122
149
  if (!_gitDirty) return "";
123
150
  const parts: string[] = [];
@@ -143,17 +170,14 @@ export default function (pi: ExtensionAPI) {
143
170
  let footerSnap: FooterSnap | undefined;
144
171
  // CWD cached from session_start — used by turn_end to refresh git dirty.
145
172
  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
- });
173
+ // cacheRead total, refreshed at session_start + agent_end. The render
174
+ // provider reads this instead of re-scanning entries every frame.
175
+ let _cacheTotal = 0;
176
+
177
+ // ── Phase-aware spinner + lifecycle ────────────────────────────
178
+ // Each event asks the editor for a phase; CardEditor.setSpinner is itself
179
+ // a same-phase no-op, so rapid event streams never reset the animation.
180
+ pi.on("turn_start", () => editor?.setSpinner("thinking"));
157
181
  pi.on("message_update", (event) => {
158
182
  const t = event.assistantMessageEvent.type;
159
183
  let next: SpinnerPhase;
@@ -161,77 +185,24 @@ export default function (pi: ExtensionAPI) {
161
185
  else if (t.startsWith("text_")) next = "outputting";
162
186
  else if (t.startsWith("toolcall_")) next = "toolcall";
163
187
  else return;
164
- if (next !== _phase) {
165
- _phase = next;
166
- editor?.setSpinner(next);
167
- }
188
+ editor?.setSpinner(next);
168
189
  });
169
- pi.on("tool_execution_start", () => {
170
- _phase = "exec";
171
- editor?.setSpinner("exec");
172
- });
173
- pi.on("agent_end", () => {
174
- _phase = null;
190
+ pi.on("tool_execution_start", () => editor?.setSpinner("exec"));
191
+ pi.on("agent_end", (_event, ctx) => {
192
+ // cacheRead totals are stable once a turn finishes — recompute here
193
+ // instead of on every render frame.
194
+ _cacheTotal = sumCacheRead(ctx);
175
195
  editor?.setSpinner(null);
176
196
  });
177
197
  pi.on("session_shutdown", () => {
178
- _phase = null;
179
198
  editor?.setSpinner(null);
180
199
  editor = undefined;
181
200
  });
182
201
 
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
202
  // Refresh git dirty after every agent turn (tools may have changed files).
203
+ // Async — never blocks the event loop; re-renders once settled.
233
204
  pi.on("turn_end", () => {
234
- if (_cwd) refreshGitDirty(_cwd);
205
+ if (_cwd) refreshGitDirty(_cwd, () => editor?.requestRender());
235
206
  });
236
207
 
237
208
  pi.on("session_start", (_event, ctx) => {
@@ -239,7 +210,8 @@ export default function (pi: ExtensionAPI) {
239
210
 
240
211
  _cwd = ctx.cwd;
241
212
  config = loadEditorShellConfig(ctx.cwd);
242
- refreshGitDirty(ctx.cwd);
213
+ _cacheTotal = sumCacheRead(ctx);
214
+ refreshGitDirty(ctx.cwd, () => editor?.requestRender());
243
215
 
244
216
  // Fresh segments on every render — reads live ctx state, so thinking /
245
217
  // context % updates show up on the next paint without extra wiring.
@@ -247,6 +219,19 @@ export default function (pi: ExtensionAPI) {
247
219
  // the default editor's behavior.
248
220
  const provider: FrameProvider = () => {
249
221
  const theme = ctx.ui.theme;
222
+
223
+ // Resolve pinned status keys → already-themed text, " · "-joined.
224
+ const buildPinned = (): string => {
225
+ const keys = config.pinnedStatus;
226
+ if (keys.length === 0 || !footerSnap) return "";
227
+ const all = footerSnap.getExtensionStatuses();
228
+ const texts = keys
229
+ .map((k) => all.get(k))
230
+ .filter((s): s is string => s != null);
231
+ if (texts.length === 0) return "";
232
+ return ` ${texts.map((s) => theme.fg("muted", s)).join(theme.fg("dim", " · "))} `;
233
+ };
234
+
250
235
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "no model";
251
236
  const thinking = pi.getThinkingLevel();
252
237
  const thinkingColor = THINKING_TOKEN[thinking] ?? "muted";
@@ -255,12 +240,13 @@ export default function (pi: ExtensionAPI) {
255
240
  const pct = usage?.percent;
256
241
  const ctxWindow = usage?.contextWindow ?? ctx.model?.contextWindow;
257
242
  const ctxText =
258
- pct !== null && pct !== undefined && ctxWindow
243
+ pct != null && ctxWindow
259
244
  ? `${Math.round(pct)}%/${(ctxWindow / 1000).toFixed(0)}k`
260
245
  : "?/??k";
261
246
 
262
- // Cache-read token total — same data source pi's own footer uses for "R14M".
263
- const cacheTokens = sumCacheRead(ctx);
247
+ // Cache-read token total — refreshed at agent_end (same data source
248
+ // pi's own footer uses for "R14M"); read from cache off the hot path.
249
+ const cacheTokens = _cacheTotal;
264
250
  const cachePart =
265
251
  cacheTokens > 0
266
252
  ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${ICON.cache} ${formatTokens(cacheTokens)}`)}`
@@ -275,31 +261,20 @@ export default function (pi: ExtensionAPI) {
275
261
  ? `${ICON.folder} ${cwdText} (${branch}${dirty})`
276
262
  : `${ICON.folder} ${cwdText}`;
277
263
 
264
+ // Model in accent; thinking label in its level token — same hue the
265
+ // border takes on, so switching levels visibly retints both together.
278
266
  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
- },
267
+ topLeft: ` ${theme.fg("accent", `${ICON.model} ${model}`)}${theme.fg("dim", " · ")}${theme.fg(thinkingColor, `${ICON.thinking} ${thinking}`)} `,
268
+ topRight: buildPinned(),
269
+ // Context in severity color; cwd stays muted so it never competes.
270
+ bottomLeft: ` ${theme.fg(contextToken(pct), `${ICON.context} ${ctxText}`)}${cachePart} `,
271
+ bottomRight: theme.fg("muted", ` ${cwdDisplay} `),
288
272
  };
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
273
  };
302
274
 
275
+ // CardEditor has its own phase-aware spinner — hide pi's built-in working loader.
276
+ ctx.ui.setWorkingVisible(false);
277
+
303
278
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
304
279
  editor = new CardEditor(tui, theme, keybindings, provider);
305
280
  return editor;
@@ -339,4 +314,50 @@ export default function (pi: ExtensionAPI) {
339
314
  };
340
315
  });
341
316
  });
317
+
318
+ // ── Debug command ──────────────────────────────────────────────
319
+ pi.registerCommand("editor-shell:status", {
320
+ description: "Show editor-shell debug state: status keys, pinned config, cache totals",
321
+ handler: async (_args, ctx) => {
322
+ const lines: string[] = [];
323
+
324
+ lines.push("[editor-shell config]");
325
+ lines.push(` pinnedStatus: [${config.pinnedStatus.join(", ")}]`);
326
+
327
+ lines.push("");
328
+ lines.push("[extension statuses]");
329
+ if (footerSnap) {
330
+ const entries = Array.from(footerSnap.getExtensionStatuses().entries());
331
+ if (entries.length === 0) {
332
+ lines.push(" (none)");
333
+ } else {
334
+ const pinned = new Set(config.pinnedStatus);
335
+ for (const [key, text] of entries.sort(([a], [b]) => a.localeCompare(b))) {
336
+ const mark = pinned.has(key) ? " ← pinned" : "";
337
+ lines.push(` ${key}: ${text}${mark}`);
338
+ }
339
+ }
340
+ } else {
341
+ lines.push(" (footer not initialized)");
342
+ }
343
+
344
+ lines.push("");
345
+ lines.push("[cache totals]");
346
+ const tokens = sumCacheRead(ctx);
347
+ lines.push(` cacheRead: ${tokens > 0 ? formatTokens(tokens) : "0"}`);
348
+
349
+ lines.push("");
350
+ lines.push(`[context] cwd: ${ctx.cwd}`);
351
+ const branch = footerSnap?.getGitBranch();
352
+ lines.push(` git branch: ${branch ?? "(not in repo)"}`);
353
+ if (branch) {
354
+ const dirty = gitDirtyDisplay().trim();
355
+ lines.push(` git dirty: ${dirty || "clean"}`);
356
+ }
357
+ const m = ctx.model;
358
+ lines.push(` model: ${m ? `${m.provider}/${m.id}` : "none"}`);
359
+
360
+ ctx.ui.notify(lines.join("\n"), "info");
361
+ },
362
+ });
342
363
  }