@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 +1 -1
- package/src/card-editor.ts +51 -53
- package/src/config.ts +17 -29
- package/src/index.ts +210 -141
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,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", //
|
|
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
|
-
|
|
56
|
+
hitRate: "\uf140", // fa-bullseye(靶心,缓存命中率)
|
|
57
|
+
folder: "\uf07c", // fa-folder
|
|
51
58
|
} as const;
|
|
52
59
|
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
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?:
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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;
|
|
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
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
165
|
-
_phase = next;
|
|
166
|
-
editor?.setSpinner(next);
|
|
167
|
-
}
|
|
218
|
+
editor?.setSpinner(next);
|
|
168
219
|
});
|
|
169
|
-
pi.on("tool_execution_start", () =>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
259
|
-
? `${
|
|
275
|
+
pct != null && ctxWindow
|
|
276
|
+
? `${pct.toFixed(1)}%/${(ctxWindow / 1000).toFixed(0)}k`
|
|
260
277
|
: "?/??k";
|
|
261
278
|
|
|
262
|
-
// Cache-read
|
|
263
|
-
|
|
279
|
+
// Cache-read tokens — per-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
|
-
|
|
266
|
-
? `${theme.fg("dim", " · ")}${theme.fg("warning", `${ICON.cache} ${formatTokens(
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
}
|