@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 +61 -0
- package/package.json +26 -0
- package/src/card-editor.ts +275 -0
- package/src/config.ts +71 -0
- package/src/index.ts +342 -0
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
|
+
}
|