@d3ara1n/pi-editor-shell 0.2.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 +1 -1
- package/src/card-editor.ts +51 -53
- package/src/config.ts +17 -29
- package/src/index.ts +155 -137
package/package.json
CHANGED
package/src/card-editor.ts
CHANGED
|
@@ -58,24 +58,16 @@ export interface FrameSegments {
|
|
|
58
58
|
bottomRight: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** Fresh, already-themed segments. The frame color
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 -
|
|
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
|
|
178
|
-
if (!
|
|
179
|
-
this.spinnerIdx = (this.spinnerIdx + 1) %
|
|
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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
const
|
|
205
|
-
const
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
60
|
-
const
|
|
61
|
-
const
|
|
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(
|
|
68
|
-
?
|
|
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 {
|
|
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
|
|
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 =
|
|
21
|
-
if (home
|
|
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
|
|
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", //
|
|
47
|
-
thinking: "\uf400", //
|
|
48
|
-
context: "\uf49b", //
|
|
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", //
|
|
56
|
+
folder: "\uf07c", // fa-folder
|
|
51
57
|
} as const;
|
|
52
58
|
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
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
|
-
|
|
165
|
-
_phase = next;
|
|
166
|
-
editor?.setSpinner(next);
|
|
167
|
-
}
|
|
188
|
+
editor?.setSpinner(next);
|
|
168
189
|
});
|
|
169
|
-
pi.on("tool_execution_start", () =>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
263
|
-
|
|
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,29 +261,15 @@ 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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
|
303
275
|
// CardEditor has its own phase-aware spinner — hide pi's built-in working loader.
|
|
@@ -342,4 +314,50 @@ export default function (pi: ExtensionAPI) {
|
|
|
342
314
|
};
|
|
343
315
|
});
|
|
344
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
|
+
});
|
|
345
363
|
}
|