@standardagents/code 0.0.2-dev.b3cdaaf → 0.1.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/README.md +19 -3
- package/dist/index.js +4811 -0
- package/dist/index.js.map +1 -0
- package/package.json +6 -5
- package/bin/standardcode.mjs +0 -25
- package/src/api.ts +0 -169
- package/src/approvals.ts +0 -42
- package/src/bridge.ts +0 -303
- package/src/credentials.ts +0 -49
- package/src/events-stream.ts +0 -99
- package/src/host-tools.ts +0 -570
- package/src/index.ts +0 -1152
- package/src/markdown.ts +0 -226
- package/src/mcp-config.ts +0 -137
- package/src/mcp.ts +0 -563
- package/src/permissions.ts +0 -53
- package/src/process-registry.ts +0 -122
- package/src/stream.ts +0 -134
- package/src/tui.ts +0 -911
- package/src/types.ts +0 -78
package/src/tui.ts
DELETED
|
@@ -1,911 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal UI with a persistent bottom input line.
|
|
3
|
-
*
|
|
4
|
-
* The input line stays anchored at the bottom and is always editable — you can
|
|
5
|
-
* type while the agent works. All transcript output is printed ABOVE it via
|
|
6
|
-
* print(). A spinner prefix on the input line indicates the agent's turn is
|
|
7
|
-
* active ("whose turn it is"), so the working indicator never disappears, even
|
|
8
|
-
* during long tool calls. Approvals and menus temporarily take over the bottom.
|
|
9
|
-
*/
|
|
10
|
-
import readline from "node:readline";
|
|
11
|
-
import { LEVELS, levelLabel, type Level } from "./types.ts";
|
|
12
|
-
|
|
13
|
-
const C = {
|
|
14
|
-
reset: "\x1b[0m",
|
|
15
|
-
dim: "\x1b[2m",
|
|
16
|
-
bold: "\x1b[1m",
|
|
17
|
-
cyan: "\x1b[36m",
|
|
18
|
-
green: "\x1b[32m",
|
|
19
|
-
yellow: "\x1b[33m",
|
|
20
|
-
red: "\x1b[31m",
|
|
21
|
-
blue: "\x1b[34m",
|
|
22
|
-
magenta: "\x1b[35m",
|
|
23
|
-
gray: "\x1b[90m",
|
|
24
|
-
teal: "\x1b[38;5;37m",
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// Dense braille frames — they fill more of the cell, so the spinner reads
|
|
28
|
-
// clearly instead of looking like faint dots.
|
|
29
|
-
const FRAMES = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"];
|
|
30
|
-
|
|
31
|
-
type KeyHandler = (str: string | undefined, key: readline.Key) => void;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* A slash command shown in the inline command palette. `name` is the word typed
|
|
35
|
-
* after `/` (e.g. "compact"); typing filters the list by it. `hint` may be a
|
|
36
|
-
* function so dynamic state (counts, level) stays fresh as the palette redraws.
|
|
37
|
-
*/
|
|
38
|
-
export interface SlashCommand {
|
|
39
|
-
name: string;
|
|
40
|
-
label: string;
|
|
41
|
-
hint?: string | (() => string);
|
|
42
|
-
run: () => void | Promise<void>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class Tui {
|
|
46
|
-
// input + indicators
|
|
47
|
-
private inputBuffer = "";
|
|
48
|
-
private cursorPos = 0; // caret index within inputBuffer (0..length)
|
|
49
|
-
private lastCursorRow = 0; // caret's row offset from the input region top, last render
|
|
50
|
-
private working = false;
|
|
51
|
-
private workingStart = 0;
|
|
52
|
-
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
53
|
-
private bgCount = 0;
|
|
54
|
-
private queuedCount = 0;
|
|
55
|
-
private subagents: string[] = []; // labels of subagents currently working (one line each)
|
|
56
|
-
private tokensIn = 0; // cumulative input tokens
|
|
57
|
-
private tokensOut = 0; // cumulative output tokens (includes the in-progress live count)
|
|
58
|
-
private contextPct: number | null = null; // % of the model context window currently used
|
|
59
|
-
|
|
60
|
-
// Inline slash-command palette: when the input starts with "/", the filtered
|
|
61
|
-
// command list renders above the prompt and arrows/enter/tab drive it.
|
|
62
|
-
private commands: SlashCommand[] = [];
|
|
63
|
-
private slashIdx = 0; // highlighted index within the FILTERED command list
|
|
64
|
-
private step: string | null = null; // current step label (e.g. "writing index.html")
|
|
65
|
-
private stepStart = 0; // when the current step began (for the step's own elapsed)
|
|
66
|
-
private stepOut = 0; // output tokens produced during the current step
|
|
67
|
-
private connected = true;
|
|
68
|
-
private bottomDrawn = false;
|
|
69
|
-
private started = false;
|
|
70
|
-
|
|
71
|
-
// takeover (approval / menu) state
|
|
72
|
-
private takeoverHandler: KeyHandler | null = null;
|
|
73
|
-
private bufferedPrints: string[] = [];
|
|
74
|
-
|
|
75
|
-
// double-press-to-quit state: the first ctrl-c arms a brief window and shows a
|
|
76
|
-
// transient hint; a second ctrl-c within the window actually quits.
|
|
77
|
-
private quitArmed = false;
|
|
78
|
-
private quitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
79
|
-
|
|
80
|
-
// bracketed-paste state
|
|
81
|
-
private pasting = false;
|
|
82
|
-
private pasteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
83
|
-
|
|
84
|
-
// event hooks (wired by index.ts)
|
|
85
|
-
onSubmit: (text: string) => void = () => {};
|
|
86
|
-
onInterrupt: () => void = () => {};
|
|
87
|
-
onUpArrow: () => void = () => {};
|
|
88
|
-
private onQuit: () => void = () => process.exit(0);
|
|
89
|
-
private levelListeners: ((l: Level) => void)[] = [];
|
|
90
|
-
|
|
91
|
-
constructor(public level: Level = 1) {
|
|
92
|
-
readline.emitKeypressEvents(process.stdin);
|
|
93
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
94
|
-
process.stdin.on("keypress", (str, key) => this.dispatch(str, key));
|
|
95
|
-
process.stdin.resume();
|
|
96
|
-
// Enable bracketed paste so a paste arrives atomically (newlines inside a
|
|
97
|
-
// paste don't fire a premature submit) and restore it on exit.
|
|
98
|
-
process.stdout.write("\x1b[?2004h");
|
|
99
|
-
// Restore bracketed paste + the cursor on exit (e.g. ctrl-c mid-menu).
|
|
100
|
-
process.on("exit", () => process.stdout.write("\x1b[?2004l\x1b[?25h"));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
get colors() {
|
|
104
|
-
return C;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
setQuitHandler(fn: () => void): void {
|
|
108
|
-
this.onQuit = fn;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Handle a ctrl-c. The first press arms a 2-second window and surfaces a
|
|
113
|
-
* transient "Press Control-C again to exit" hint in the bottom region; a
|
|
114
|
-
* second press within the window quits. After the window lapses the hint
|
|
115
|
-
* clears and the next ctrl-c starts over — so it always takes two.
|
|
116
|
-
*/
|
|
117
|
-
requestQuit(): void {
|
|
118
|
-
if (this.quitArmed) {
|
|
119
|
-
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
120
|
-
this.quitTimer = null;
|
|
121
|
-
this.quitArmed = false;
|
|
122
|
-
this.onQuit();
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
this.quitArmed = true;
|
|
126
|
-
this.renderBottom();
|
|
127
|
-
this.quitTimer = setTimeout(() => {
|
|
128
|
-
this.quitArmed = false;
|
|
129
|
-
this.quitTimer = null;
|
|
130
|
-
this.renderBottom();
|
|
131
|
-
}, 2000);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Tear down the bottom region and restore the terminal (called on quit). */
|
|
135
|
-
end(): void {
|
|
136
|
-
if (this.quitTimer) clearTimeout(this.quitTimer);
|
|
137
|
-
this.quitTimer = null;
|
|
138
|
-
this.clearBottom();
|
|
139
|
-
process.stdout.write("\x1b[?2004l\x1b[?25h");
|
|
140
|
-
}
|
|
141
|
-
onLevelChange(fn: (l: Level) => void): void {
|
|
142
|
-
this.levelListeners.push(fn);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Carrot colour by level — cooler/safer (low) to warmer/permissive (high). */
|
|
146
|
-
levelColor(): string {
|
|
147
|
-
return { 1: C.cyan, 2: C.green, 3: C.yellow, 4: C.magenta, 5: C.red }[this.level];
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ─── key dispatch ──────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
private dispatch(str: string | undefined, key: readline.Key): void {
|
|
153
|
-
if (key && key.ctrl && key.name === "c") {
|
|
154
|
-
this.requestQuit();
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
if (key && key.name === "tab" && key.shift) {
|
|
158
|
-
this.cycleLevel();
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
// Bracketed paste: everything between the start/end markers is literal text,
|
|
162
|
-
// even newlines. Handle it before any other key logic so a pasted Return
|
|
163
|
-
// doesn't submit and a pasted "/" doesn't open the menu.
|
|
164
|
-
const seq = (key && key.sequence) || str || "";
|
|
165
|
-
if (!this.pasting && seq.includes("\x1b[200~")) {
|
|
166
|
-
this.pasting = true;
|
|
167
|
-
this.armPasteSafety();
|
|
168
|
-
this.handlePasteChunk(seq.slice(seq.indexOf("\x1b[200~") + 6));
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (this.pasting) {
|
|
172
|
-
this.armPasteSafety();
|
|
173
|
-
this.handlePasteChunk(seq);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// A takeover (approval/menu) owns the keyboard while active.
|
|
178
|
-
if (this.takeoverHandler) {
|
|
179
|
-
this.takeoverHandler(str, key);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (!key) return;
|
|
183
|
-
|
|
184
|
-
// Inline slash-command palette owns navigation while the input starts with
|
|
185
|
-
// "/". Editing keys (printable/backspace/left/right) fall through to the
|
|
186
|
-
// normal input logic below, which re-filters the palette on the next render.
|
|
187
|
-
if (this.paletteOpen()) {
|
|
188
|
-
const matches = this.filteredCommands();
|
|
189
|
-
const cur = matches.length ? Math.min(this.slashIdx, matches.length - 1) : 0;
|
|
190
|
-
if (key.name === "up") {
|
|
191
|
-
if (matches.length) {
|
|
192
|
-
this.slashIdx = (cur - 1 + matches.length) % matches.length;
|
|
193
|
-
this.renderBottom();
|
|
194
|
-
}
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
if (key.name === "down") {
|
|
198
|
-
if (matches.length) {
|
|
199
|
-
this.slashIdx = (cur + 1) % matches.length;
|
|
200
|
-
this.renderBottom();
|
|
201
|
-
}
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if (key.name === "tab") {
|
|
205
|
-
if (matches.length) {
|
|
206
|
-
this.inputBuffer = "/" + matches[cur].name;
|
|
207
|
-
this.cursorPos = this.inputBuffer.length;
|
|
208
|
-
this.slashIdx = 0;
|
|
209
|
-
this.renderBottom();
|
|
210
|
-
}
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
if (key.name === "return" || key.name === "enter") {
|
|
214
|
-
if (matches.length) this.runCommand(matches[cur]);
|
|
215
|
-
return; // never submit a "/…" line as a chat message
|
|
216
|
-
}
|
|
217
|
-
if (key.name === "escape") {
|
|
218
|
-
this.inputBuffer = "";
|
|
219
|
-
this.cursorPos = 0;
|
|
220
|
-
this.slashIdx = 0;
|
|
221
|
-
this.renderBottom();
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
// any other key falls through to normal editing (palette re-filters live)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (key.name === "escape") {
|
|
228
|
-
this.onInterrupt();
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
// Caret movement within the (possibly wrapped) input.
|
|
232
|
-
if (key.name === "left") {
|
|
233
|
-
if (this.cursorPos > 0) {
|
|
234
|
-
this.cursorPos--;
|
|
235
|
-
this.renderBottom();
|
|
236
|
-
}
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
if (key.name === "right") {
|
|
240
|
-
if (this.cursorPos < this.inputBuffer.length) {
|
|
241
|
-
this.cursorPos++;
|
|
242
|
-
this.renderBottom();
|
|
243
|
-
}
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
if (key.name === "home" || (key.ctrl && key.name === "a")) {
|
|
247
|
-
this.cursorPos = 0;
|
|
248
|
-
this.renderBottom();
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
if (key.name === "end" || (key.ctrl && key.name === "e")) {
|
|
252
|
-
this.cursorPos = this.inputBuffer.length;
|
|
253
|
-
this.renderBottom();
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
// Up with an empty input edits the last queued message (see onUpArrow).
|
|
257
|
-
if (key.name === "up") {
|
|
258
|
-
this.onUpArrow();
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
if (key.name === "return" || key.name === "enter") {
|
|
262
|
-
const text = this.inputBuffer;
|
|
263
|
-
this.inputBuffer = "";
|
|
264
|
-
this.cursorPos = 0;
|
|
265
|
-
this.renderBottom();
|
|
266
|
-
if (text.trim()) this.onSubmit(text.trim());
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
if (key.name === "backspace") {
|
|
270
|
-
if (this.cursorPos > 0) {
|
|
271
|
-
this.inputBuffer =
|
|
272
|
-
this.inputBuffer.slice(0, this.cursorPos - 1) + this.inputBuffer.slice(this.cursorPos);
|
|
273
|
-
this.cursorPos--;
|
|
274
|
-
this.slashIdx = 0;
|
|
275
|
-
this.renderBottom();
|
|
276
|
-
}
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
if (key.name === "delete") {
|
|
280
|
-
if (this.cursorPos < this.inputBuffer.length) {
|
|
281
|
-
this.inputBuffer =
|
|
282
|
-
this.inputBuffer.slice(0, this.cursorPos) + this.inputBuffer.slice(this.cursorPos + 1);
|
|
283
|
-
this.slashIdx = 0;
|
|
284
|
-
this.renderBottom();
|
|
285
|
-
}
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
if (key.ctrl || key.meta || key.name === "tab") return;
|
|
289
|
-
if (str && str >= " ") {
|
|
290
|
-
this.insertAtCursor(str);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
private insertAtCursor(text: string): void {
|
|
295
|
-
this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + text + this.inputBuffer.slice(this.cursorPos);
|
|
296
|
-
this.cursorPos += text.length;
|
|
297
|
-
this.slashIdx = 0; // re-anchor the palette highlight to the top match as the filter changes
|
|
298
|
-
this.renderBottom();
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Insert a paste fragment at the caret; collapse newlines (single-line input). */
|
|
302
|
-
private handlePasteChunk(chunk: string): void {
|
|
303
|
-
const end = chunk.indexOf("\x1b[201~");
|
|
304
|
-
const content = (end >= 0 ? chunk.slice(0, end) : chunk).replace(/[\r\n]+/g, " ");
|
|
305
|
-
if (content) {
|
|
306
|
-
this.inputBuffer =
|
|
307
|
-
this.inputBuffer.slice(0, this.cursorPos) + content + this.inputBuffer.slice(this.cursorPos);
|
|
308
|
-
this.cursorPos += content.length;
|
|
309
|
-
}
|
|
310
|
-
if (end >= 0) {
|
|
311
|
-
this.pasting = false;
|
|
312
|
-
if (this.pasteTimer) {
|
|
313
|
-
clearTimeout(this.pasteTimer);
|
|
314
|
-
this.pasteTimer = null;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
this.renderBottom();
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Never let a missed end-marker wedge the input: clear paste mode shortly. */
|
|
321
|
-
private armPasteSafety(): void {
|
|
322
|
-
if (this.pasteTimer) clearTimeout(this.pasteTimer);
|
|
323
|
-
this.pasteTimer = setTimeout(() => {
|
|
324
|
-
this.pasting = false;
|
|
325
|
-
this.pasteTimer = null;
|
|
326
|
-
this.renderBottom();
|
|
327
|
-
}, 2000);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private cycleLevel(): void {
|
|
331
|
-
const idx = LEVELS.indexOf(this.level);
|
|
332
|
-
this.setLevel(LEVELS[(idx + 1) % LEVELS.length]);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
setLevel(level: Level): void {
|
|
336
|
-
if (level === this.level) return;
|
|
337
|
-
this.level = level;
|
|
338
|
-
this.levelListeners.forEach((fn) => fn(this.level));
|
|
339
|
-
this.print(`${this.levelColor()}${levelLabel(this.level)}${C.reset}`);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// ─── bottom input line ───────────────────────────────────────────────────
|
|
343
|
-
|
|
344
|
-
/** Begin showing the persistent input line. */
|
|
345
|
-
start(): void {
|
|
346
|
-
this.started = true;
|
|
347
|
-
this.renderBottom();
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** Format an elapsed millisecond span as 45s / 2m 05s / 1h 05m. */
|
|
351
|
-
private formatElapsed(ms: number): string {
|
|
352
|
-
const s = Math.floor(ms / 1000);
|
|
353
|
-
if (s < 60) return `${s}s`;
|
|
354
|
-
const m = Math.floor(s / 60);
|
|
355
|
-
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
356
|
-
const h = Math.floor(m / 60);
|
|
357
|
-
return `${h}h ${String(m % 60).padStart(2, "0")}m`;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Compact token count: 945 → "945", 12345 → "12.35k", 1_250_000 → "1.25M". */
|
|
361
|
-
private fmtTokens(n: number): string {
|
|
362
|
-
if (n < 1000) return String(n);
|
|
363
|
-
if (n < 1_000_000) return `${(n / 1000).toFixed(2)}k`;
|
|
364
|
-
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
private spinnerFrame(): string {
|
|
368
|
-
return `${C.bold}${C.cyan}${FRAMES[Math.floor(Date.now() / 100) % FRAMES.length]}${C.reset}`;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* "↑X ↓Y" cumulative token totals (greyed — low-priority), plus a context
|
|
373
|
-
* window gauge "ctx N%" when known. The gauge colour ramps with fill (green →
|
|
374
|
-
* yellow → red) so the user can see compaction approaching at a glance.
|
|
375
|
-
*/
|
|
376
|
-
private tokensText(): string {
|
|
377
|
-
const parts: string[] = [];
|
|
378
|
-
if (this.tokensIn > 0) parts.push(`↑${this.fmtTokens(this.tokensIn)}`);
|
|
379
|
-
if (this.tokensOut > 0) parts.push(`↓${this.fmtTokens(this.tokensOut)}`);
|
|
380
|
-
let out = parts.length ? `${C.gray}${parts.join(" ")}${C.reset}` : "";
|
|
381
|
-
if (this.contextPct != null) {
|
|
382
|
-
const pct = this.contextPct;
|
|
383
|
-
const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : C.green;
|
|
384
|
-
const gauge = `${col}ctx ${pct}%${C.reset}`;
|
|
385
|
-
out = out ? `${out} ${gauge}` : gauge;
|
|
386
|
-
}
|
|
387
|
-
return out;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Set the context-window fill percentage (0–100), or null to hide it.
|
|
392
|
-
* Driven by the runtime's `context_usage` KV (latest request input tokens ÷
|
|
393
|
-
* model context window).
|
|
394
|
-
*/
|
|
395
|
-
setContextPct(pct: number | null): void {
|
|
396
|
-
const next = pct == null ? null : Math.max(0, Math.min(100, Math.round(pct)));
|
|
397
|
-
if (next === this.contextPct) return;
|
|
398
|
-
this.contextPct = next;
|
|
399
|
-
this.renderBottom();
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** Plain "ctx N%" label (no ANSI) for menu hints, or "" when unknown. */
|
|
403
|
-
contextPctLabel(): string {
|
|
404
|
-
return this.contextPct == null ? "" : `ctx ${this.contextPct}%`;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** Register the slash commands shown in the inline `/` palette. */
|
|
408
|
-
setCommands(commands: SlashCommand[]): void {
|
|
409
|
-
this.commands = commands;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** Is the `/` command palette currently showing? (input starts with "/".) */
|
|
413
|
-
private paletteOpen(): boolean {
|
|
414
|
-
return this.started && !this.takeoverHandler && this.commands.length > 0 && this.inputBuffer.startsWith("/");
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/** Commands matching the text typed after "/", in declared order. */
|
|
418
|
-
private filteredCommands(): SlashCommand[] {
|
|
419
|
-
if (!this.inputBuffer.startsWith("/")) return [];
|
|
420
|
-
const q = this.inputBuffer.slice(1).trim().toLowerCase();
|
|
421
|
-
if (q === "") return this.commands;
|
|
422
|
-
return this.commands.filter(
|
|
423
|
-
(c) => c.name.startsWith(q) || c.name.includes(q) || c.label.toLowerCase().includes(q)
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
private runCommand(cmd: SlashCommand): void {
|
|
428
|
-
this.inputBuffer = "";
|
|
429
|
-
this.cursorPos = 0;
|
|
430
|
-
this.slashIdx = 0;
|
|
431
|
-
this.renderBottom();
|
|
432
|
-
void Promise.resolve(cmd.run()).catch(() => {});
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* The summary line ABOVE the prompt. While working it reads
|
|
437
|
-
* `⣷ Working <step> <elapsed> ↑in ↓out`; when idle it keeps the cumulative
|
|
438
|
-
* token totals visible (`↑in ↓out`) so they live in the summary rather than
|
|
439
|
-
* crowding the prompt. Null when idle with nothing counted yet.
|
|
440
|
-
*/
|
|
441
|
-
private statusLineText(cols: number): string | null {
|
|
442
|
-
const tk = this.tokensText();
|
|
443
|
-
if (this.working) {
|
|
444
|
-
const el = this.formatElapsed(Date.now() - this.workingStart);
|
|
445
|
-
const right = `${C.dim}${el}${C.reset}${tk ? " " + tk : ""}`;
|
|
446
|
-
const head = `${this.spinnerFrame()} ${C.bold}Working${C.reset}`;
|
|
447
|
-
const avail = Math.max(0, cols - this.visibleWidth(head) - this.visibleWidth(right) - 2);
|
|
448
|
-
let stepPart = "";
|
|
449
|
-
if (this.step && avail > 1) {
|
|
450
|
-
let s = this.step;
|
|
451
|
-
if (s.length > avail) s = s.slice(0, avail - 1) + "…";
|
|
452
|
-
stepPart = ` ${C.dim}${s}${C.reset}`;
|
|
453
|
-
}
|
|
454
|
-
return `${head}${stepPart} ${right}`;
|
|
455
|
-
}
|
|
456
|
-
return tk ? tk : null;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** The prompt line prefix (with ANSI colour) that precedes the typed text. */
|
|
460
|
-
private promptPrefix(): string {
|
|
461
|
-
// Just the queue/bg badges and the level carrot — metrics live on the summary
|
|
462
|
-
// line above so they never crowd the input.
|
|
463
|
-
const q = this.queuedCount > 0 ? `${C.yellow}[⏳ ${this.queuedCount} queued]${C.reset} ` : "";
|
|
464
|
-
const bg = this.bgCount > 0 ? `${C.cyan}[⚙ ${this.bgCount} bg]${C.reset} ` : "";
|
|
465
|
-
return `${q}${bg}${this.levelColor()}›${C.reset} `;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
private visibleWidth(s: string): number {
|
|
469
|
-
// eslint-disable-next-line no-control-regex
|
|
470
|
-
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Build the slash-palette rows for the current filter. Each row is clamped to
|
|
475
|
-
* ONE physical line (a wrapped row would desync the move-up redraw), with the
|
|
476
|
-
* `/name` highlighted, the label dimmed, and the hint right-aligned.
|
|
477
|
-
*/
|
|
478
|
-
private paletteLines(cols: number): string[] {
|
|
479
|
-
if (!this.paletteOpen()) return [];
|
|
480
|
-
const matches = this.filteredCommands();
|
|
481
|
-
if (matches.length === 0) return [` ${C.gray}no matching command${C.reset}`];
|
|
482
|
-
const cur = Math.min(this.slashIdx, matches.length - 1);
|
|
483
|
-
const pointerW = 2;
|
|
484
|
-
return matches.map((cmd, i) => {
|
|
485
|
-
const sel = i === cur;
|
|
486
|
-
const hint = (typeof cmd.hint === "function" ? cmd.hint() : cmd.hint) ?? "";
|
|
487
|
-
const hintW = hint.length;
|
|
488
|
-
const name = `/${cmd.name}`;
|
|
489
|
-
let visible = `${name} ${cmd.label}`;
|
|
490
|
-
const labelMax = Math.max(6, cols - pointerW - (hintW ? hintW + 2 : 0));
|
|
491
|
-
if (visible.length > labelMax) visible = visible.slice(0, labelMax - 1) + "…";
|
|
492
|
-
const desc = visible.slice(name.length); // " <label…>" (or "…" if name was clipped)
|
|
493
|
-
const pointer = sel ? `${C.magenta}❯${C.reset} ` : " ";
|
|
494
|
-
const nameStyled = sel ? `${C.bold}${C.cyan}${name}${C.reset}` : `${C.cyan}${name}${C.reset}`;
|
|
495
|
-
let line = `${pointer}${nameStyled}${C.gray}${desc}${C.reset}`;
|
|
496
|
-
if (hintW) {
|
|
497
|
-
const gap = Math.max(2, cols - pointerW - visible.length - hintW);
|
|
498
|
-
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
499
|
-
}
|
|
500
|
-
return line;
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** Move the cursor to the top-left of the current bottom region. */
|
|
505
|
-
private moveToRegionTop(): void {
|
|
506
|
-
process.stdout.write("\r");
|
|
507
|
-
if (this.bottomDrawn && this.lastCursorRow > 0) process.stdout.write(`\x1b[${this.lastCursorRow}A`);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Render the bottom region: an optional step line, then the prompt + input
|
|
512
|
-
* (wrapping across as many rows as needed), with the caret placed at cursorPos.
|
|
513
|
-
* Uses only relative cursor moves so it survives terminal scrolling when the
|
|
514
|
-
* region grows near the bottom of the screen.
|
|
515
|
-
*/
|
|
516
|
-
private renderBottom(): void {
|
|
517
|
-
if (!this.started || this.takeoverHandler) return; // not yet active, or a takeover owns the bottom
|
|
518
|
-
const cols = process.stdout.columns || 80;
|
|
519
|
-
|
|
520
|
-
this.moveToRegionTop();
|
|
521
|
-
process.stdout.write("\x1b[J"); // clear the old region (cursor → end of screen)
|
|
522
|
-
|
|
523
|
-
// Transient disconnect notice (one row, above everything). It lives in the
|
|
524
|
-
// bottom region, so it shows while we're reconnecting and CLEARS itself the
|
|
525
|
-
// moment we reconnect — no lingering "lost connection" line in the transcript.
|
|
526
|
-
const noticeLine = this.connected
|
|
527
|
-
? null
|
|
528
|
-
: `${C.yellow}⚠ lost connection to the workspace — reconnecting…${C.reset}`;
|
|
529
|
-
const noticeRows = noticeLine ? 1 : 0;
|
|
530
|
-
if (noticeLine) process.stdout.write(noticeLine + "\r\n");
|
|
531
|
-
|
|
532
|
-
// Transient "press again to exit" hint (one row), cleared when the window
|
|
533
|
-
// lapses or a second ctrl-c quits.
|
|
534
|
-
const quitLine = this.quitArmed ? `${C.dim}Press Control-C again to exit${C.reset}` : null;
|
|
535
|
-
const quitRows = quitLine ? 1 : 0;
|
|
536
|
-
if (quitLine) process.stdout.write(quitLine + "\r\n");
|
|
537
|
-
|
|
538
|
-
// Subagent lines (one per active subagent), above the summary line. A magenta
|
|
539
|
-
// spinner distinguishes background subagent work from the main agent's loader.
|
|
540
|
-
const frame = FRAMES[Math.floor(Date.now() / 100) % FRAMES.length];
|
|
541
|
-
for (const label of this.subagents) {
|
|
542
|
-
const line = `${C.magenta}${frame}${C.reset} ${C.magenta}${label}${C.reset} ${C.dim}working${C.reset}`;
|
|
543
|
-
process.stdout.write(line + "\r\n");
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Summary line (one physical row, above the prompt): working state + tokens.
|
|
547
|
-
const statusLine = this.statusLineText(cols);
|
|
548
|
-
const statusRows = statusLine ? 1 : 0;
|
|
549
|
-
if (statusLine) process.stdout.write(statusLine + "\r\n");
|
|
550
|
-
|
|
551
|
-
// Inline slash-command palette (one clamped physical row per command), just
|
|
552
|
-
// above the prompt so it reads like an autocomplete. Filters as you type.
|
|
553
|
-
const paletteLines = this.paletteLines(cols);
|
|
554
|
-
for (const line of paletteLines) process.stdout.write(line + "\r\n");
|
|
555
|
-
|
|
556
|
-
const aboveRows = noticeRows + quitRows + this.subagents.length + statusRows + paletteLines.length;
|
|
557
|
-
|
|
558
|
-
// Prompt + input (wraps).
|
|
559
|
-
const prefix = this.promptPrefix();
|
|
560
|
-
const pw = this.visibleWidth(prefix);
|
|
561
|
-
const buf = this.inputBuffer;
|
|
562
|
-
process.stdout.write(prefix + buf);
|
|
563
|
-
|
|
564
|
-
const inputRows = Math.max(1, Math.ceil((pw + buf.length) / cols));
|
|
565
|
-
if (this.cursorPos < buf.length) {
|
|
566
|
-
// Caret is inside the text — reposition from the last physical row.
|
|
567
|
-
const curCell = pw + this.cursorPos;
|
|
568
|
-
const cursorRowInInput = Math.floor(curCell / cols);
|
|
569
|
-
const cursorCol = curCell % cols;
|
|
570
|
-
process.stdout.write("\r"); // col 0 of the last physical row (cancels pending-wrap)
|
|
571
|
-
const up = inputRows - 1 - cursorRowInInput;
|
|
572
|
-
if (up > 0) process.stdout.write(`\x1b[${up}A`);
|
|
573
|
-
if (cursorCol > 0) process.stdout.write(`\x1b[${cursorCol}C`);
|
|
574
|
-
this.lastCursorRow = aboveRows + cursorRowInInput;
|
|
575
|
-
} else {
|
|
576
|
-
// Caret at the end — leave it where writing left it.
|
|
577
|
-
this.lastCursorRow = aboveRows + (inputRows - 1);
|
|
578
|
-
}
|
|
579
|
-
this.bottomDrawn = true;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
private clearBottom(): void {
|
|
583
|
-
if (!this.bottomDrawn) return;
|
|
584
|
-
this.moveToRegionTop();
|
|
585
|
-
process.stdout.write("\x1b[J");
|
|
586
|
-
this.bottomDrawn = false;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** Print a line of transcript above the persistent input. */
|
|
590
|
-
print(text: string): void {
|
|
591
|
-
if (this.takeoverHandler) {
|
|
592
|
-
this.bufferedPrints.push(text);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
this.clearBottom();
|
|
596
|
-
process.stdout.write(text + "\n");
|
|
597
|
-
this.renderBottom();
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** Multi-line convenience. */
|
|
601
|
-
printLines(lines: string[]): void {
|
|
602
|
-
for (const l of lines) this.print(l);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* Print a user message as a highlighted block so it stands out in the
|
|
607
|
-
* transcript (à la Codex). The bar is sized to the text (not full width, which
|
|
608
|
-
* would wrap awkwardly), padded with a space on each side and a blank line
|
|
609
|
-
* above and below, and a teal `›` marks the first row.
|
|
610
|
-
*/
|
|
611
|
-
printUserMessage(text: string): void {
|
|
612
|
-
const cols = Math.max(20, process.stdout.columns || 80);
|
|
613
|
-
const bg = "\x1b[48;5;238m"; // dark-gray background bar (visible against the terminal)
|
|
614
|
-
const limit = Math.max(8, cols - 6); // wrap width, leaving room for padding
|
|
615
|
-
const words = text.replace(/\s+/g, " ").trim().split(" ");
|
|
616
|
-
const lines: string[] = [];
|
|
617
|
-
let cur = "";
|
|
618
|
-
for (let w of words) {
|
|
619
|
-
while (w.length > limit) {
|
|
620
|
-
if (cur) {
|
|
621
|
-
lines.push(cur);
|
|
622
|
-
cur = "";
|
|
623
|
-
}
|
|
624
|
-
lines.push(w.slice(0, limit));
|
|
625
|
-
w = w.slice(limit);
|
|
626
|
-
}
|
|
627
|
-
if (!cur) cur = w;
|
|
628
|
-
else if (cur.length + 1 + w.length <= limit) cur += " " + w;
|
|
629
|
-
else {
|
|
630
|
-
lines.push(cur);
|
|
631
|
-
cur = w;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
if (cur || !lines.length) lines.push(cur);
|
|
635
|
-
// Width the bar to the longest line + the 2-col marker/indent.
|
|
636
|
-
const innerW = 2 + Math.max(...lines.map((l) => l.length));
|
|
637
|
-
this.print(""); // padding above
|
|
638
|
-
lines.forEach((line, i) => {
|
|
639
|
-
const rowText = (i === 0 ? "› " : " ") + line;
|
|
640
|
-
const padded = rowText.padEnd(innerW);
|
|
641
|
-
// recolor the leading marker; one space of padding inside the bar each side
|
|
642
|
-
const inner = i === 0 ? `${C.teal}›${C.reset}${bg}${padded.slice(1)}` : padded;
|
|
643
|
-
this.print(`${bg} ${inner} ${C.reset}`);
|
|
644
|
-
});
|
|
645
|
-
this.print(""); // padding below
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// ─── working indicator (turn state) ───────────────────────────────────────
|
|
649
|
-
|
|
650
|
-
setWorking(on: boolean): void {
|
|
651
|
-
if (on && !this.working) {
|
|
652
|
-
this.working = true;
|
|
653
|
-
this.workingStart = Date.now();
|
|
654
|
-
} else if (!on) {
|
|
655
|
-
this.working = false;
|
|
656
|
-
}
|
|
657
|
-
this.syncSpinner();
|
|
658
|
-
this.renderBottom();
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/** Labels of subagents currently working, one persistent line each. */
|
|
662
|
-
setSubagents(labels: string[]): void {
|
|
663
|
-
this.subagents = labels;
|
|
664
|
-
this.syncSpinner();
|
|
665
|
-
this.renderBottom();
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/** Run the spinner animation while anything (the agent or a subagent) is active. */
|
|
669
|
-
private syncSpinner(): void {
|
|
670
|
-
const spinning = this.working || this.subagents.length > 0;
|
|
671
|
-
if (spinning && !this.spinnerTimer) {
|
|
672
|
-
this.spinnerTimer = setInterval(() => this.renderBottom(), 100);
|
|
673
|
-
} else if (!spinning && this.spinnerTimer) {
|
|
674
|
-
clearInterval(this.spinnerTimer);
|
|
675
|
-
this.spinnerTimer = null;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
get isWorking(): boolean {
|
|
680
|
-
return this.working;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
setBackgroundCount(n: number): void {
|
|
684
|
-
this.bgCount = n;
|
|
685
|
-
this.renderBottom();
|
|
686
|
-
}
|
|
687
|
-
setQueuedCount(n: number): void {
|
|
688
|
-
this.queuedCount = n;
|
|
689
|
-
this.renderBottom();
|
|
690
|
-
}
|
|
691
|
-
setConnected(connected: boolean): void {
|
|
692
|
-
this.connected = connected;
|
|
693
|
-
this.renderBottom();
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// ─── input buffer access (for up-arrow editing of queued messages) ─────────
|
|
697
|
-
|
|
698
|
-
getInput(): string {
|
|
699
|
-
return this.inputBuffer;
|
|
700
|
-
}
|
|
701
|
-
setInput(text: string): void {
|
|
702
|
-
this.inputBuffer = text;
|
|
703
|
-
this.cursorPos = text.length;
|
|
704
|
-
this.renderBottom();
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/** Cumulative token totals shown on the prompt line (`outTokens` includes live). */
|
|
708
|
-
setTokens(inTokens: number, outTokens: number): void {
|
|
709
|
-
this.tokensIn = inTokens;
|
|
710
|
-
this.tokensOut = outTokens;
|
|
711
|
-
this.renderBottom();
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Current step the agent is working on, shown on the line above the prompt.
|
|
716
|
-
* `outTokens` is the output produced during this step. The step's own timer
|
|
717
|
-
* resets whenever the label changes.
|
|
718
|
-
*/
|
|
719
|
-
setStep(label: string | null, outTokens: number): void {
|
|
720
|
-
const next = label && label.trim() ? label.trim() : null;
|
|
721
|
-
if (next !== this.step) {
|
|
722
|
-
this.step = next;
|
|
723
|
-
this.stepStart = Date.now();
|
|
724
|
-
}
|
|
725
|
-
this.stepOut = outTokens;
|
|
726
|
-
this.renderBottom();
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// ─── takeover helpers (approval / menus) ───────────────────────────────────
|
|
730
|
-
|
|
731
|
-
private beginTakeover(): void {
|
|
732
|
-
this.clearBottom();
|
|
733
|
-
process.stdout.write("\r\x1b[K");
|
|
734
|
-
process.stdout.write("\x1b[?25l"); // hide the text cursor while a menu/prompt owns the screen
|
|
735
|
-
this.bottomDrawn = false;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
private endTakeover(): void {
|
|
739
|
-
this.takeoverHandler = null;
|
|
740
|
-
process.stdout.write("\x1b[?25h"); // restore the cursor
|
|
741
|
-
const buffered = this.bufferedPrints;
|
|
742
|
-
this.bufferedPrints = [];
|
|
743
|
-
for (const t of buffered) {
|
|
744
|
-
process.stdout.write(t + "\n");
|
|
745
|
-
}
|
|
746
|
-
this.renderBottom();
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input. */
|
|
750
|
-
approval(question: string, risk: number): Promise<"allow" | "deny" | "always" | "always_risk"> {
|
|
751
|
-
return new Promise((resolve) => {
|
|
752
|
-
const options: { value: "allow" | "always" | "always_risk" | "deny"; label: string; shortcut: string; color: string }[] = [
|
|
753
|
-
{ value: "allow", label: "Allow once", shortcut: "y", color: C.green },
|
|
754
|
-
{ value: "always", label: "Always allow this tool", shortcut: "a", color: C.cyan },
|
|
755
|
-
{ value: "always_risk", label: `Allow all level ${risk} this session`, shortcut: "l", color: C.cyan },
|
|
756
|
-
{ value: "deny", label: "Deny", shortcut: "n", color: C.red },
|
|
757
|
-
];
|
|
758
|
-
let idx = 0;
|
|
759
|
-
const riskBar = `${C.red}${"●".repeat(risk)}${C.gray}${"○".repeat(5 - risk)}${C.reset}`;
|
|
760
|
-
|
|
761
|
-
this.beginTakeover();
|
|
762
|
-
process.stdout.write(
|
|
763
|
-
`\n${C.yellow}┃${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}\n` +
|
|
764
|
-
`${C.yellow}┃${C.reset} ${question}\n`
|
|
765
|
-
);
|
|
766
|
-
const renderLine = (i: number): string => {
|
|
767
|
-
const o = options[i];
|
|
768
|
-
const sel = i === idx;
|
|
769
|
-
const pointer = sel ? `${o.color}❯${C.reset}` : " ";
|
|
770
|
-
const label = sel ? `${C.bold}${o.label}${C.reset}` : o.label;
|
|
771
|
-
return `${C.yellow}┃${C.reset} ${pointer} ${label} ${C.gray}(${o.shortcut})${C.reset}`;
|
|
772
|
-
};
|
|
773
|
-
const draw = (moveUp: boolean) => {
|
|
774
|
-
if (moveUp) process.stdout.write(`\x1b[${options.length}A`);
|
|
775
|
-
for (let i = 0; i < options.length; i++) process.stdout.write(`\r\x1b[K${renderLine(i)}\n`);
|
|
776
|
-
};
|
|
777
|
-
draw(false);
|
|
778
|
-
|
|
779
|
-
const finish = (choice: "allow" | "deny" | "always" | "always_risk") => {
|
|
780
|
-
this.endTakeover();
|
|
781
|
-
resolve(choice);
|
|
782
|
-
};
|
|
783
|
-
this.takeoverHandler = (str, key) => {
|
|
784
|
-
if (key?.name === "up" || str === "k") {
|
|
785
|
-
idx = (idx - 1 + options.length) % options.length;
|
|
786
|
-
draw(true);
|
|
787
|
-
} else if (key?.name === "down" || str === "j") {
|
|
788
|
-
idx = (idx + 1) % options.length;
|
|
789
|
-
draw(true);
|
|
790
|
-
} else if (key?.name === "return" || key?.name === "enter") {
|
|
791
|
-
finish(options[idx].value);
|
|
792
|
-
} else {
|
|
793
|
-
const k = (str || "").toLowerCase();
|
|
794
|
-
if (k === "y") finish("allow");
|
|
795
|
-
else if (k === "a") finish("always");
|
|
796
|
-
else if (k === "l") finish("always_risk");
|
|
797
|
-
else if (k === "n" || key?.name === "escape") finish("deny");
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
/** Arrow-key selection menu (slash menu, process menu, resume). Pauses input. */
|
|
804
|
-
select<T>(title: string, items: { label: string; hint?: string; value: T }[]): Promise<T | undefined> {
|
|
805
|
-
return new Promise((resolve) => {
|
|
806
|
-
let idx = 0;
|
|
807
|
-
this.beginTakeover();
|
|
808
|
-
if (title) process.stdout.write(`\n${title}\n\n`);
|
|
809
|
-
// Each row is clamped to ONE physical line (preview left, hint right-aligned)
|
|
810
|
-
// so long session previews can't wrap — a wrapped row would desync the
|
|
811
|
-
// "move up N lines" redraw and corrupt the menu on every keystroke.
|
|
812
|
-
const renderLine = (i: number): string => {
|
|
813
|
-
const w = process.stdout.columns || 80;
|
|
814
|
-
const it = items[i];
|
|
815
|
-
const sel = i === idx;
|
|
816
|
-
const hint = it.hint ?? "";
|
|
817
|
-
const hintW = hint.length;
|
|
818
|
-
const pointerW = 2; // "❯ " or " "
|
|
819
|
-
const labelMax = Math.max(6, w - 2 - pointerW - (hintW ? hintW + 2 : 0));
|
|
820
|
-
let label = it.label;
|
|
821
|
-
if (label.length > labelMax) label = label.slice(0, labelMax - 1) + "…";
|
|
822
|
-
const pointer = sel ? `${C.magenta}❯${C.reset} ` : " ";
|
|
823
|
-
const styledLabel = sel ? `${C.bold}${C.cyan}${label}${C.reset}` : label;
|
|
824
|
-
let line = `${pointer}${styledLabel}`;
|
|
825
|
-
if (hintW) {
|
|
826
|
-
const gap = Math.max(2, w - 2 - pointerW - label.length - hintW);
|
|
827
|
-
line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
|
|
828
|
-
}
|
|
829
|
-
return line;
|
|
830
|
-
};
|
|
831
|
-
const draw = (moveUp: boolean) => {
|
|
832
|
-
if (moveUp) process.stdout.write(`\x1b[${items.length}A`);
|
|
833
|
-
for (let i = 0; i < items.length; i++) process.stdout.write(`\r\x1b[K${renderLine(i)}\n`);
|
|
834
|
-
};
|
|
835
|
-
draw(false);
|
|
836
|
-
// Erase the whole menu block (title + rows) when it closes so it doesn't
|
|
837
|
-
// linger in the scrollback. After draw the cursor sits one row below the
|
|
838
|
-
// last item; the title block above occupies 3 rows (blank/title/blank).
|
|
839
|
-
const titleRows = title ? 3 : 0;
|
|
840
|
-
const erase = () => {
|
|
841
|
-
process.stdout.write("\r");
|
|
842
|
-
const up = titleRows + items.length;
|
|
843
|
-
if (up > 0) process.stdout.write(`\x1b[${up}A`);
|
|
844
|
-
process.stdout.write("\x1b[J");
|
|
845
|
-
};
|
|
846
|
-
const close = (value: T | undefined) => {
|
|
847
|
-
erase();
|
|
848
|
-
this.endTakeover();
|
|
849
|
-
resolve(value);
|
|
850
|
-
};
|
|
851
|
-
this.takeoverHandler = (str, key) => {
|
|
852
|
-
if (!key) return;
|
|
853
|
-
if (key.name === "up" || str === "k") {
|
|
854
|
-
idx = (idx - 1 + items.length) % items.length;
|
|
855
|
-
draw(true);
|
|
856
|
-
} else if (key.name === "down" || str === "j") {
|
|
857
|
-
idx = (idx + 1) % items.length;
|
|
858
|
-
draw(true);
|
|
859
|
-
} else if (key.name === "return" || key.name === "enter") {
|
|
860
|
-
close(items[idx].value);
|
|
861
|
-
} else if (key.name === "escape") {
|
|
862
|
-
close(undefined);
|
|
863
|
-
}
|
|
864
|
-
};
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* Free-text prompt (single line). Pauses the main input and reads a line —
|
|
870
|
-
* used where a menu can't, e.g. entering an MCP server command. Enter submits,
|
|
871
|
-
* Escape (or empty submit) cancels with null.
|
|
872
|
-
*/
|
|
873
|
-
prompt(question: string, placeholder = ""): Promise<string | null> {
|
|
874
|
-
return new Promise((resolve) => {
|
|
875
|
-
let buf = "";
|
|
876
|
-
this.beginTakeover();
|
|
877
|
-
process.stdout.write("\x1b[?25h"); // we want a visible caret while typing
|
|
878
|
-
process.stdout.write(`\n${C.cyan}┃${C.reset} ${question}\n`);
|
|
879
|
-
if (placeholder) process.stdout.write(`${C.gray}┃ e.g. ${placeholder}${C.reset}\n`);
|
|
880
|
-
const draw = () => {
|
|
881
|
-
process.stdout.write(`\r\x1b[K${C.cyan}┃${C.reset} ${C.bold}›${C.reset} ${buf}`);
|
|
882
|
-
};
|
|
883
|
-
draw();
|
|
884
|
-
const finish = (value: string | null) => {
|
|
885
|
-
process.stdout.write("\n");
|
|
886
|
-
this.endTakeover();
|
|
887
|
-
resolve(value);
|
|
888
|
-
};
|
|
889
|
-
this.takeoverHandler = (str, key) => {
|
|
890
|
-
if (key?.name === "escape") return finish(null);
|
|
891
|
-
if (key?.name === "return" || key?.name === "enter") return finish(buf.trim() || null);
|
|
892
|
-
if (key?.name === "backspace") {
|
|
893
|
-
buf = buf.slice(0, -1);
|
|
894
|
-
draw();
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
// Ignore control keys; accept printable input (including pasted text).
|
|
898
|
-
if (str && !key?.ctrl && !key?.meta && str >= " ") {
|
|
899
|
-
buf += str;
|
|
900
|
-
draw();
|
|
901
|
-
}
|
|
902
|
-
};
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
banner(lines: string[]): void {
|
|
907
|
-
this.clearBottom();
|
|
908
|
-
process.stdout.write("\n");
|
|
909
|
-
for (const l of lines) process.stdout.write(l + "\n");
|
|
910
|
-
}
|
|
911
|
-
}
|