@d3ara1n/pi-editor-shell 0.1.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 ADDED
@@ -0,0 +1,61 @@
1
+ # pi-editor-shell
2
+
3
+ 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. No Nerd Font required for the frame itself.
4
+
5
+ ## What shows up where
6
+
7
+ - **Top border** — ` model · thinking-level ` (left) + pinned extension statuses (right, via `pinnedStatus` config)
8
+ - **Bottom border** — ` ctx NN%/NNNk · cache-tokens ` (left) + ` ~current/dir (branch) ` (right, includes git branch when available)
9
+ - **Below shell** — Auto-wrapping extension status line (all `setStatus` entries not pinned to the top)
10
+ - **Border color** follows pi's thinking-level / bash-mode indicator automatically.
11
+
12
+ All segments are re-read from live session state on every paint, so switching thinking level or burning context updates the frame on the next render with no extra wiring.
13
+
14
+ ## Configuration
15
+
16
+ In `~/.pi/agent/settings.json` under the `editorShell` key:
17
+
18
+ ```jsonc
19
+ {
20
+ "editorShell": {
21
+ // Status keys to pin to the top-right corner of the shell.
22
+ // Only keys set via ctx.ui.setStatus() are eligible.
23
+ "pinnedStatus": ["subagent", "access-denied"]
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## Commands
29
+
30
+ | Command | Description |
31
+ |---------|-------------|
32
+ | `/editor-shell:status` | Show debug info: pinned config, all extension statuses with their keys, cache totals |
33
+
34
+ ## How it works
35
+
36
+ The default pi editor only draws a horizontal line above and below the input area (no side borders), and a separate footer renders the status bar. This extension replaces both — it wraps the built-in `CustomEditor`, renders it at `width - 2`, wraps every line with left/right glyphs, and embeds the status bar information (extension statuses) below the shell. The total width is unchanged. Border color follows pi's `borderColor` (which encodes thinking level / bash mode), so the shell stays semantically consistent and reacts to theme changes automatically.
37
+
38
+ ## Installation
39
+
40
+ Add the package directory to the `extensions` array in `~/.pi/agent/settings.json`:
41
+
42
+ ```jsonc
43
+ {
44
+ "extensions": [
45
+ "/absolute/path/to/pi-extensions/packages/pi-editor-shell"
46
+ ]
47
+ }
48
+ ```
49
+
50
+ Then run `/reload` or restart pi.
51
+
52
+ ## Caveats
53
+
54
+ - **Mutually exclusive** with other editor-replacing extensions (`border-status-editor`, `rainbow-editor`, `modal-editor`, …). Disable those when enabling this one — `setEditorComponent` is last-writer-wins.
55
+ - When the content scrolls, pi's native `↑ N more` / `↓ N more` indicators are replaced by the embedded status text (status takes precedence).
56
+ - Falls back to the default editor below `MIN_WIDTH` (20 columns).
57
+
58
+ ## Dependencies
59
+
60
+ - [`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
61
+ - [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui)
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@d3ara1n/pi-editor-shell",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Replaces pi's default editor and status bar with a unified rounded-corner shell — no Nerd Font required",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi"
9
+ ],
10
+ "main": "src/index.ts",
11
+ "peerDependencies": {
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "@earendil-works/pi-tui": "*"
14
+ },
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/d3ara1n/pi-extensions",
24
+ "directory": "packages/pi-editor-shell"
25
+ }
26
+ }
@@ -0,0 +1,275 @@
1
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ // Pull the constructor parameter types straight off CustomEditor so they are
5
+ // structurally identical to what `super(...)` expects. Importing TUI /
6
+ // EditorTheme from "@earendil-works/pi-tui" would resolve to this repo's
7
+ // top-level copy, which is a *different* declaration than the one nested
8
+ // inside @earendil-works/pi-coding-agent — the two are not assignable.
9
+ type CtorArgs = ConstructorParameters<typeof CustomEditor>;
10
+
11
+ // Box-drawing glyphs (U+2500 block). Supported by virtually every monospace
12
+ // font — no Nerd Font required for the frame itself.
13
+ const GLYPH = {
14
+ topLeft: "╭",
15
+ topRight: "╮",
16
+ bottomLeft: "╰",
17
+ bottomRight: "╯",
18
+ vertical: "│",
19
+ // T-junction caps: used when autocomplete splits the card into two stacked
20
+ // panes. ctx/cwd live on this divider so they always sit just under the
21
+ // editor, regardless of whether the popup is open.
22
+ divLeft: "├",
23
+ divRight: "┤",
24
+ } as const;
25
+
26
+ /** Below this terminal width the frame hurts readability — fall back to default. */
27
+ const MIN_WIDTH = 20;
28
+
29
+ const SPINNER_INTERVAL_MS = 80;
30
+
31
+ /** Agent phase — each gets its own spinner animation. */
32
+ export type SpinnerPhase = "thinking" | "outputting" | "toolcall" | "exec";
33
+
34
+ /** Spinner frames keyed by agent phase, chosen for semantic fit:
35
+ * thinking — ●/○ pulse, doubled frames to halve the tempo
36
+ * outputting — braille sand-pile (dots accumulate bottom-up, then clear)
37
+ * toolcall — ▓▒░ breathing light (single char, fades in/out)
38
+ * exec — ◜◝◞◟ arc rotation (tool spinning, mechanical) */
39
+ const SPINNERS: Record<SpinnerPhase, readonly string[]> = {
40
+ thinking: ["●", "●", "○", "○"],
41
+ outputting: ["⡀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷", "⣿"],
42
+ toolcall: ["▓", "▒", "░", "░", "▒"],
43
+ exec: ["◜", "◝", "◞", "◟"],
44
+ };
45
+
46
+ const RESET = "\x1b[0m";
47
+
48
+ /**
49
+ * Already-themed text segments to embed in the four border positions.
50
+ * Empty string = no segment; the gap filler expands to fill the space.
51
+ * The extension (not this class) is responsible for applying colors, so the
52
+ * editor never has to touch the private `theme` field of the base Editor.
53
+ */
54
+ export interface FrameSegments {
55
+ topLeft: string;
56
+ topRight: string;
57
+ bottomLeft: string;
58
+ bottomRight: string;
59
+ }
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;
75
+
76
+ const EMPTY_FRAME: Frame = {
77
+ segments: { topLeft: "", topRight: "", bottomLeft: "", bottomRight: "" },
78
+ frame: (s) => s,
79
+ };
80
+
81
+ /** Strip ANSI SGR escapes and pi's zero-width cursor marker so a line can
82
+ * be inspected by visible content alone. Border rows never carry cursor
83
+ * markers or hyperlinks, so this light treatment is sufficient.
84
+ *
85
+ * Control chars are built via String.fromCharCode so the regex source holds
86
+ * no literal escape sequences — that keeps linters that ban control
87
+ * characters inside regex literals happy while still matching real ESC/BEL. */
88
+ const SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
89
+ const CURSOR_MARKER = `${String.fromCharCode(27)}_pi:c${String.fromCharCode(7)}`;
90
+
91
+ function stripAnsi(s: string): string {
92
+ return s.replace(SGR_RE, "").replaceAll(CURSOR_MARKER, "");
93
+ }
94
+
95
+ /** A horizontal border row is all box-drawing `─` (plus whitespace),
96
+ * optionally holding a scroll indicator like `↑ 3 more`. Anything else —
97
+ * editor content, autocomplete items — contains other visible glyphs. */
98
+ function isBorderRow(line: string): boolean {
99
+ const core = stripAnsi(line).replace(/[─\s]/g, "");
100
+ return core === "" || /^[↑↓]\d+more$/.test(core);
101
+ }
102
+
103
+ /**
104
+ * Lay out one frame row: `leftCap leftText ──fill── rightText rightCap`,
105
+ * total visible width == `width`. Caps and the `─` filler take `borderColor`;
106
+ * the text segments are pre-themed by the caller. Long segments are
107
+ * truncated (right first, then left) so a minimum gap survives.
108
+ */
109
+ function fitFrameRow(
110
+ leftCap: string,
111
+ rightCap: string,
112
+ leftText: string,
113
+ rightText: string,
114
+ width: number,
115
+ border: (s: string) => string,
116
+ ): string {
117
+ if (width <= 0) return "";
118
+ if (width === 1) return border(leftCap);
119
+ if (width === 2) return `${border(leftCap)}${border(rightCap)}`;
120
+
121
+ const inner = width - 2; // two caps
122
+ const minGap = 3;
123
+ let l = leftText;
124
+ let r = rightText;
125
+
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), "");
131
+ }
132
+
133
+ const gap = Math.max(0, inner - visibleWidth(l) - visibleWidth(r));
134
+ return `${border(leftCap)}${l}${border("─".repeat(gap))}${r}${border(rightCap)}`;
135
+ }
136
+
137
+ /**
138
+ * Editor that wraps the built-in input area in a rounded-corner card frame,
139
+ * with optional status text embedded in the top/bottom borders.
140
+ *
141
+ * The default Editor only draws a horizontal line above and below the content
142
+ * (no side borders — see Editor.render). To get a closed box we render the
143
+ * editor at `width - 2` and wrap every line with a left/right glyph, so the
144
+ * total visible width still equals `width`. Each rendered line is computed
145
+ * fresh every frame (no cached themed strings), so theme changes and
146
+ * thinking/bash border-color shifts apply automatically with no invalidate
147
+ * work needed. Border glyphs use `this.borderColor`, the same function pi
148
+ * mutates to encode thinking level / bash mode, keeping the frame
149
+ * semantically consistent.
150
+ */
151
+ export class CardEditor extends CustomEditor {
152
+ private readonly frameProvider?: FrameProvider;
153
+ private spinnerPhase: SpinnerPhase | null = null;
154
+ private spinnerIdx = 0;
155
+ private spinnerTimer?: ReturnType<typeof setInterval>;
156
+
157
+ constructor(
158
+ tui: CtorArgs[0],
159
+ theme: CtorArgs[1],
160
+ keybindings: CtorArgs[2],
161
+ frameProvider?: FrameProvider,
162
+ ) {
163
+ super(tui, theme, keybindings, { paddingX: 1 });
164
+ this.frameProvider = frameProvider;
165
+ }
166
+
167
+ /** Set the active spinner phase. `null` stops the spinner.
168
+ * Same-phase calls are no-ops so rapid event streams don't reset the
169
+ * animation index; phase transitions (thinking→outputting, etc.) do reset. */
170
+ setSpinner(phase: SpinnerPhase | null): void {
171
+ if (phase === this.spinnerPhase) return;
172
+ this.spinnerPhase = phase;
173
+ if (phase) {
174
+ this.spinnerIdx = 0;
175
+ if (!this.spinnerTimer) {
176
+ this.spinnerTimer = setInterval(() => {
177
+ const frames = this.spinnerPhase ? SPINNERS[this.spinnerPhase] : null;
178
+ if (!frames) return;
179
+ this.spinnerIdx = (this.spinnerIdx + 1) % frames.length;
180
+ this.tui.requestRender();
181
+ }, SPINNER_INTERVAL_MS);
182
+ }
183
+ } else {
184
+ if (this.spinnerTimer) clearInterval(this.spinnerTimer);
185
+ this.spinnerTimer = undefined;
186
+ }
187
+ this.tui.requestRender();
188
+ }
189
+
190
+ render(width: number): string[] {
191
+ // Too narrow — delegate to the default rendering, untouched.
192
+ if (width < MIN_WIDTH) {
193
+ return super.render(width);
194
+ }
195
+
196
+ // super.render(width - 2) guarantees every line's visible width == width - 2,
197
+ // so wrapping each line with one glyph on each side lands exactly on `width`.
198
+ const inner = super.render(width - 2);
199
+ if (inner.length === 0) return inner;
200
+
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;
207
+ // While the agent is active, the current phase spinner replaces the
208
+ // model text in the top-left slot — a moving indicator reads as "busy"
209
+ // more strongly than a static label.
210
+ const topLeft = this.spinnerPhase
211
+ ? `${RESET}${border(` ${SPINNERS[this.spinnerPhase][this.spinnerIdx % SPINNERS[this.spinnerPhase].length]} `)}${seg.topLeft.trimStart()}`
212
+ : seg.topLeft;
213
+
214
+ // The default Editor appends autocomplete rows *after* the bottom border.
215
+ // So the real bottom border is the last row that still looks like one —
216
+ // not necessarily `inner.length - 1`. Treating an autocomplete item as
217
+ // the bottom border (the old `i === last` check) fed it through
218
+ // fitFrameRow, which truncated/filled the item text and rendered the
219
+ // popup empty.
220
+ let bottomIdx = inner.length - 1;
221
+ while (bottomIdx > 0 && !isBorderRow(inner[bottomIdx])) bottomIdx--;
222
+ // inner[0] is always the top border, so a real bottom border is ≥ 1.
223
+ // (Editor.render always pushes one; this only guards the degenerate case.)
224
+ if (bottomIdx === 0) bottomIdx = inner.length - 1;
225
+
226
+ // When the popup is open, pi's bottom border sits *between* the editor
227
+ // content and the popup items. We turn that middle border into a
228
+ // T-junction divider (carrying ctx/cwd), wrap the popup items with the
229
+ // same verticals as content, and close everything with a fresh rounded
230
+ // bottom — one connected card, two panes.
231
+ const hasPopup = bottomIdx < inner.length - 1;
232
+
233
+ const out: string[] = [];
234
+ for (let i = 0; i < inner.length; i++) {
235
+ const line = inner[i];
236
+ if (i === 0) {
237
+ // Top border: rebuild with embedded status text (status takes
238
+ // precedence over pi's plain ─ / "↑ N more" scroll indicator).
239
+ out.push(fitFrameRow(GLYPH.topLeft, GLYPH.topRight, topLeft, seg.topRight, width, border));
240
+ } else if (i === bottomIdx) {
241
+ // ctx/cwd always sit just under the editor. With a popup below, this
242
+ // border becomes a T-junction divider; without one it's the rounded
243
+ // 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
+ );
263
+ } else {
264
+ // Content row and popup items alike live inside the card. Reset around
265
+ // the text so its styling never leaks into the frame and vice versa.
266
+ out.push(`${border(GLYPH.vertical)}${RESET}${line}${RESET}${border(GLYPH.vertical)}`);
267
+ }
268
+ }
269
+ // The popup extends the card — close it with an empty rounded bottom.
270
+ if (hasPopup) {
271
+ out.push(fitFrameRow(GLYPH.bottomLeft, GLYPH.bottomRight, "", "", width, border));
272
+ }
273
+ return out;
274
+ }
275
+ }
package/src/config.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Read editor-shell configuration from settings files.
3
+ *
4
+ * Global (~/.pi/agent/settings.json) + project (.pi/settings.json),
5
+ * project overrides global.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+
12
+ export interface EditorShellConfig {
13
+ /**
14
+ * Status keys to pin to the shell's top-right corner.
15
+ * Only keys set via ctx.ui.setStatus() are eligible.
16
+ */
17
+ pinnedStatus: string[];
18
+ }
19
+
20
+ export const DEFAULT_CONFIG: EditorShellConfig = {
21
+ pinnedStatus: [],
22
+ };
23
+
24
+ function getAgentDir(): string {
25
+ const envDir = process.env.PI_AGENT_DIR;
26
+ if (envDir) return envDir;
27
+ return path.join(os.homedir(), ".pi", "agent");
28
+ }
29
+
30
+ function readSettingsFile(filePath: string): any {
31
+ try {
32
+ if (!fs.existsSync(filePath)) return {};
33
+ const content = fs.readFileSync(filePath, "utf-8");
34
+ return JSON.parse(content);
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
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
+ /**
55
+ * Load editorShell config from merged settings.
56
+ * @param cwd - Project working directory
57
+ */
58
+ 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;
64
+ if (!raw) return { ...DEFAULT_CONFIG };
65
+
66
+ return {
67
+ pinnedStatus: Array.isArray(raw.pinnedStatus)
68
+ ? raw.pinnedStatus.filter((k: any) => typeof k === "string")
69
+ : DEFAULT_CONFIG.pinnedStatus,
70
+ };
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,342 @@
1
+ import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { spawnSync } from "node:child_process";
4
+ import { CardEditor, type FrameProvider, type SpinnerPhase } from "./card-editor";
5
+ import { loadEditorShellConfig, type EditorShellConfig } from "./config";
6
+
7
+ /**
8
+ * pi-editor-shell — Replaces pi's default editor and status bar with a
9
+ * unified rounded-corner shell, embedding status info in the border:
10
+ * model · thinking level on top, context % + cwd on the bottom.
11
+ * Border color follows pi's thinking/bash indicator automatically.
12
+ *
13
+ * Caveat: `setEditorComponent` is a *replacement* API — mutually exclusive
14
+ * with other editor-replacing extensions (border-status-editor,
15
+ * rainbow-editor, modal-editor, …). Disable those when enabling this one.
16
+ */
17
+
18
+ /** Collapse $HOME to `~` for display. */
19
+ function formatCwd(cwd: string): string {
20
+ const home = process.env.HOME;
21
+ if (home && cwd.startsWith(home)) return `~${cwd.slice(home.length)}`;
22
+ return cwd;
23
+ }
24
+
25
+ /** Thinking level → its dedicated theme token, so the label tints the same
26
+ * color pi applies to the border on that level (strongest "linked" feel). */
27
+ const THINKING_TOKEN: Record<string, ThemeColor> = {
28
+ off: "thinkingOff",
29
+ minimal: "thinkingMinimal",
30
+ low: "thinkingLow",
31
+ medium: "thinkingMedium",
32
+ high: "thinkingHigh",
33
+ xhigh: "thinkingXhigh",
34
+ };
35
+
36
+ /** Context-fill severity by usage ratio — green / amber / red. */
37
+ function contextToken(pct: number | null | undefined): ThemeColor {
38
+ if (pct === null || pct === undefined) return "muted";
39
+ if (pct >= 80) return "error";
40
+ if (pct >= 50) return "warning";
41
+ return "success";
42
+ }
43
+
44
+ // ── Nerd Font icons (Octicons + FontAwesome) ──────────────────────────
45
+ const ICON = {
46
+ model: "\uf4bc", //  oct-cpu
47
+ thinking: "\uf400", //  oct-light-bulb
48
+ context: "\uf49b", //  oct-cache
49
+ cache: "\u26a1", // ⚡ oct-zap
50
+ folder: "\uf07c", //  fa-folder
51
+ } as const;
52
+
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. */
57
+ interface MsgSnap {
58
+ role: string;
59
+ usage?: { cacheRead?: number };
60
+ }
61
+ interface EntrySnap {
62
+ type: string;
63
+ message?: MsgSnap;
64
+ }
65
+
66
+ /** Sum cache-read tokens across all assistant messages,
67
+ * matching pi's own footer filtering (type === "message"). */
68
+ function sumCacheRead(ctx: { sessionManager: { getEntries(): unknown[] } }): number {
69
+ let total = 0;
70
+ for (const entry of ctx.sessionManager.getEntries()) {
71
+ const e = entry as EntrySnap;
72
+ if (e.type !== "message" || e.message?.role !== "assistant" || !e.message.usage) continue;
73
+ total += e.message.usage.cacheRead ?? 0;
74
+ }
75
+ return total;
76
+ }
77
+
78
+ /** Format a token count for display: 14000000 → "14.0M", 132000 → "132.0k". */
79
+ function formatTokens(n: number): string {
80
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
81
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
82
+ return String(n);
83
+ }
84
+
85
+ // ── Git dirty state (event-driven, not TTL) ───────────────────────
86
+ // Refreshed at session_start and after every agent turn (turn_end).
87
+ interface GitDirty {
88
+ staged: number;
89
+ unstaged: number;
90
+ }
91
+ let _gitDirty: GitDirty | undefined;
92
+
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;
117
+ }
118
+ }
119
+
120
+ /** Format dirty state as pi-style "+2 ~1" string, or "" if clean / unknown. */
121
+ function gitDirtyDisplay(): string {
122
+ if (!_gitDirty) return "";
123
+ const parts: string[] = [];
124
+ if (_gitDirty.staged > 0) parts.push(`+${_gitDirty.staged}`);
125
+ if (_gitDirty.unstaged > 0) parts.push(`~${_gitDirty.unstaged}`);
126
+ return parts.length ? ` ${parts.join(" ")}` : "";
127
+ }
128
+
129
+ /** Minimal footer-data shape — just enough to read extension status
130
+ * texts and the current git branch. */
131
+ type FooterSnap = {
132
+ getExtensionStatuses(): ReadonlyMap<string, string>;
133
+ getGitBranch(): string | null;
134
+ };
135
+
136
+ export default function (pi: ExtensionAPI) {
137
+ // The factory may run again when pi rebuilds the editor (model switch,
138
+ // reload, …), so always drive whichever instance is current.
139
+ let editor: CardEditor | undefined;
140
+ let config: EditorShellConfig = { pinnedStatus: [] };
141
+ // Shared footer-data ref — the provider (running inside CardEditor.render)
142
+ // reads it to resolve pinned status keys to their current text.
143
+ let footerSnap: FooterSnap | undefined;
144
+ // CWD cached from session_start — used by turn_end to refresh git dirty.
145
+ 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
+ });
157
+ pi.on("message_update", (event) => {
158
+ const t = event.assistantMessageEvent.type;
159
+ let next: SpinnerPhase;
160
+ if (t.startsWith("thinking_")) next = "thinking";
161
+ else if (t.startsWith("text_")) next = "outputting";
162
+ else if (t.startsWith("toolcall_")) next = "toolcall";
163
+ else return;
164
+ if (next !== _phase) {
165
+ _phase = next;
166
+ editor?.setSpinner(next);
167
+ }
168
+ });
169
+ pi.on("tool_execution_start", () => {
170
+ _phase = "exec";
171
+ editor?.setSpinner("exec");
172
+ });
173
+ pi.on("agent_end", () => {
174
+ _phase = null;
175
+ editor?.setSpinner(null);
176
+ });
177
+ pi.on("session_shutdown", () => {
178
+ _phase = null;
179
+ editor?.setSpinner(null);
180
+ editor = undefined;
181
+ });
182
+
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
+ // Refresh git dirty after every agent turn (tools may have changed files).
233
+ pi.on("turn_end", () => {
234
+ if (_cwd) refreshGitDirty(_cwd);
235
+ });
236
+
237
+ pi.on("session_start", (_event, ctx) => {
238
+ if (!ctx.hasUI) return;
239
+
240
+ _cwd = ctx.cwd;
241
+ config = loadEditorShellConfig(ctx.cwd);
242
+ refreshGitDirty(ctx.cwd);
243
+
244
+ // Fresh segments on every render — reads live ctx state, so thinking /
245
+ // context % updates show up on the next paint without extra wiring.
246
+ // The border color itself is left to pi (editor.borderColor), matching
247
+ // the default editor's behavior.
248
+ const provider: FrameProvider = () => {
249
+ const theme = ctx.ui.theme;
250
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "no model";
251
+ const thinking = pi.getThinkingLevel();
252
+ const thinkingColor = THINKING_TOKEN[thinking] ?? "muted";
253
+
254
+ const usage = ctx.getContextUsage();
255
+ const pct = usage?.percent;
256
+ const ctxWindow = usage?.contextWindow ?? ctx.model?.contextWindow;
257
+ const ctxText =
258
+ pct !== null && pct !== undefined && ctxWindow
259
+ ? `${Math.round(pct)}%/${(ctxWindow / 1000).toFixed(0)}k`
260
+ : "?/??k";
261
+
262
+ // Cache-read token total — same data source pi's own footer uses for "R14M".
263
+ const cacheTokens = sumCacheRead(ctx);
264
+ const cachePart =
265
+ cacheTokens > 0
266
+ ? `${theme.fg("dim", " · ")}${theme.fg("warning", `${ICON.cache} ${formatTokens(cacheTokens)}`)}`
267
+ : "";
268
+
269
+ // Git branch + dirty state — pi's format: ~/Projects (main).
270
+ const cwdText = formatCwd(ctx.cwd);
271
+ const branch = footerSnap?.getGitBranch() ?? null;
272
+ const dirty = branch ? gitDirtyDisplay() : "";
273
+ const cwdDisplay =
274
+ branch && branch !== "detached"
275
+ ? `${ICON.folder} ${cwdText} (${branch}${dirty})`
276
+ : `${ICON.folder} ${cwdText}`;
277
+
278
+ 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
+ },
288
+ };
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
+ };
302
+
303
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
304
+ editor = new CardEditor(tui, theme, keybindings, provider);
305
+ return editor;
306
+ });
307
+
308
+ // Replace pi's built-in footer with an auto-wrapping extension-status
309
+ // line below the shell. Each status item is atomic — wrapping breaks
310
+ // between items, never mid-word.
311
+ ctx.ui.setFooter((_tui, theme, footerData) => {
312
+ footerSnap = footerData;
313
+ return {
314
+ render(width: number): string[] {
315
+ const pinned = new Set(config.pinnedStatus);
316
+ const statuses = Array.from(footerData.getExtensionStatuses().entries())
317
+ .filter(([key]) => !pinned.has(key))
318
+ .sort(([a], [b]) => a.localeCompare(b))
319
+ .map(([, text]) => text);
320
+ if (statuses.length === 0) return [];
321
+
322
+ const sep = theme.fg("dim", " · ");
323
+ const lines: string[] = [];
324
+ let current = "";
325
+ for (const s of statuses) {
326
+ const candidate = current ? `${current}${sep}${s}` : s;
327
+ if (visibleWidth(candidate) <= width) {
328
+ current = candidate;
329
+ } else {
330
+ if (current) lines.push(current);
331
+ current = s;
332
+ }
333
+ }
334
+ if (current) lines.push(current);
335
+ return lines;
336
+ },
337
+ invalidate() {},
338
+ dispose() {},
339
+ };
340
+ });
341
+ });
342
+ }