subrouter-cli 0.0.0-stage → 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/CLAUDE.md +51 -0
- package/README.md +397 -2
- package/config.example.json +9 -0
- package/package.json +21 -4
- package/scripts/sse-harness.ts +252 -0
- package/scripts/sub-wrapper.sh +11 -0
- package/src/adapters/anthropic.ts +311 -0
- package/src/adapters/openai.ts +227 -0
- package/src/approval.ts +21 -0
- package/src/chatviewport.ts +123 -0
- package/src/client.ts +398 -0
- package/src/clipboard.ts +62 -0
- package/src/commandpolicy.ts +272 -0
- package/src/commands.ts +18 -0
- package/src/config.ts +107 -0
- package/src/effort.ts +18 -0
- package/src/images.ts +77 -0
- package/src/index.ts +248 -0
- package/src/lineinput.ts +726 -0
- package/src/loop.ts +201 -0
- package/src/markdown.ts +244 -0
- package/src/repl.ts +700 -0
- package/src/sessions.ts +58 -0
- package/src/sse.ts +64 -0
- package/src/terminal.ts +228 -0
- package/src/token.ts +36 -0
- package/src/toolpreview.ts +54 -0
- package/src/tools.ts +790 -0
- package/src/types.ts +73 -0
- package/src/ui.ts +813 -0
- package/src/usage.ts +186 -0
- package/test/absolute-tools.test.ts +296 -0
- package/test/absolute-ui.test.ts +153 -0
- package/test/adapters.test.ts +205 -0
- package/test/anthropic.test.ts +246 -0
- package/test/auto-ui.test.ts +106 -0
- package/test/chatviewport.test.ts +49 -0
- package/test/client.test.ts +327 -0
- package/test/clipboard.test.ts +63 -0
- package/test/command-input.test.ts +183 -0
- package/test/command-tools.test.ts +141 -0
- package/test/commandpolicy.test.ts +252 -0
- package/test/disk-tools.test.ts +220 -0
- package/test/effort.test.ts +123 -0
- package/test/fixtures/openai-tools.sse +84 -0
- package/test/image-adapters.test.ts +78 -0
- package/test/image-ui.test.ts +151 -0
- package/test/images.test.ts +60 -0
- package/test/loop.test.ts +387 -0
- package/test/m5.test.ts +127 -0
- package/test/markdown.test.ts +201 -0
- package/test/repl-ui.test.ts +293 -0
- package/test/sse.test.ts +74 -0
- package/test/steering-ui.test.ts +182 -0
- package/test/steering.test.ts +68 -0
- package/test/terminal-ui.test.ts +144 -0
- package/test/terminal.test.ts +229 -0
- package/test/toolpreview.test.ts +51 -0
- package/test/tools.test.ts +227 -0
- package/test/ui.test.ts +635 -0
- package/test/usage-footer.test.ts +180 -0
- package/tsconfig.json +17 -0
package/src/lineinput.ts
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
// lineinput.ts — raw-mode line editor for the REPL (macOS Terminal).
|
|
2
|
+
// Bracketed paste submits multi-line pastes as one turn; Ctrl+C interrupts, Ctrl+D
|
|
3
|
+
// exits, arrows walk history, Ctrl+A/E and left/right move the cursor. Piped stdin
|
|
4
|
+
// falls back to plain readline so `echo "…" | sub` keeps working.
|
|
5
|
+
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
8
|
+
import { ui, violet, bold, dim } from './ui.ts';
|
|
9
|
+
import { matchingCommands } from './commands.ts';
|
|
10
|
+
|
|
11
|
+
export interface LineInputHandlers {
|
|
12
|
+
onLine(line: string): void;
|
|
13
|
+
onInterrupt(): void;
|
|
14
|
+
onEof(): void;
|
|
15
|
+
onPasteImage?(): void;
|
|
16
|
+
onClearImages?(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PASTE_START = '\x1b[200~';
|
|
20
|
+
const PASTE_END = '\x1b[201~';
|
|
21
|
+
/** Visible rows in the picker menu; the list scrolls under this window when longer. */
|
|
22
|
+
const MENU_HEIGHT = 15;
|
|
23
|
+
|
|
24
|
+
/** Pure key-to-action core — separated so the editor logic stays testable. */
|
|
25
|
+
export function editLine(
|
|
26
|
+
chars: string,
|
|
27
|
+
state: { buffer: string; cursor: number },
|
|
28
|
+
): { buffer: string; cursor: number; submit?: string; interrupt?: boolean } {
|
|
29
|
+
const out = { ...state };
|
|
30
|
+
for (const c of chars) {
|
|
31
|
+
if (c === '\r' || c === '\n') {
|
|
32
|
+
return { ...out, submit: out.buffer };
|
|
33
|
+
}
|
|
34
|
+
if (c === '\x03') {
|
|
35
|
+
return { buffer: '', cursor: 0, interrupt: true };
|
|
36
|
+
}
|
|
37
|
+
if (c === '\x7f' || c === '\b') {
|
|
38
|
+
if (out.cursor > 0) {
|
|
39
|
+
out.buffer = out.buffer.slice(0, out.cursor - 1) + out.buffer.slice(out.cursor);
|
|
40
|
+
out.cursor--;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '\x01') {
|
|
45
|
+
out.cursor = 0; // Ctrl+A home
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (c === '\x05') {
|
|
49
|
+
out.cursor = out.buffer.length; // Ctrl+E end
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '\x15') {
|
|
53
|
+
out.buffer = ''; // Ctrl+U kill line
|
|
54
|
+
out.cursor = 0;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === '\x0b') {
|
|
58
|
+
out.buffer = out.buffer.slice(0, out.cursor); // Ctrl+K kill to end
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (c >= ' ') {
|
|
62
|
+
out.buffer = out.buffer.slice(0, out.cursor) + c + out.buffer.slice(out.cursor);
|
|
63
|
+
out.cursor++;
|
|
64
|
+
}
|
|
65
|
+
// other control chars are ignored
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class LineInput {
|
|
71
|
+
private handlers: LineInputHandlers;
|
|
72
|
+
private prompt = violet('❯ ');
|
|
73
|
+
private model = '';
|
|
74
|
+
private buffer = '';
|
|
75
|
+
private cursor = 0;
|
|
76
|
+
private history: string[] = [];
|
|
77
|
+
private histIndex = 0;
|
|
78
|
+
private pasting = false;
|
|
79
|
+
private pasteBuf = '';
|
|
80
|
+
private esc = '';
|
|
81
|
+
private decoder = new StringDecoder('utf8');
|
|
82
|
+
private fallback: readline.Interface | null = null;
|
|
83
|
+
private questionResolve: ((answer: string) => void) | null = null;
|
|
84
|
+
private echoQuestion = true;
|
|
85
|
+
private stopped = false;
|
|
86
|
+
private paused = false;
|
|
87
|
+
private working = false;
|
|
88
|
+
private questionDraft: { buffer: string; cursor: number; histIndex: number; prompt: string } | null = null;
|
|
89
|
+
private selectItems: string[] | null = null;
|
|
90
|
+
private selectIndex = 0;
|
|
91
|
+
private selectTop = 0;
|
|
92
|
+
private selectResolve: ((index: number | null) => void) | null = null;
|
|
93
|
+
private selectEsc = '';
|
|
94
|
+
private menuShown = false;
|
|
95
|
+
private dockMenu = false;
|
|
96
|
+
private selectEscapeTimer: NodeJS.Timeout | null = null;
|
|
97
|
+
private paletteOpen = false;
|
|
98
|
+
private paletteIndex = 0;
|
|
99
|
+
private paletteText = '';
|
|
100
|
+
private paletteDismissed = false;
|
|
101
|
+
private viewResolve: (() => void) | null = null;
|
|
102
|
+
private viewRefresh: (() => Promise<string[]>) | null = null;
|
|
103
|
+
private viewEsc = '';
|
|
104
|
+
private viewRefreshing = false;
|
|
105
|
+
private navigation = '';
|
|
106
|
+
private navigationTimer: NodeJS.Timeout | null = null;
|
|
107
|
+
|
|
108
|
+
/** Live read-only command panel; arrows scroll, r refreshes, Enter/Esc dismisses. */
|
|
109
|
+
view(lines: string[], refresh: () => Promise<string[]>): Promise<void> {
|
|
110
|
+
this.closePalette();
|
|
111
|
+
if (!ui.panel(lines, () => this.closeView())) {
|
|
112
|
+
lines.forEach((line) => ui.say(line));
|
|
113
|
+
return Promise.resolve();
|
|
114
|
+
}
|
|
115
|
+
this.viewRefresh = refresh;
|
|
116
|
+
return new Promise((resolve) => { this.viewResolve = resolve; });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
updateView(lines: string[]): void {
|
|
120
|
+
if (this.viewResolve) ui.panel(lines);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private closeView(): void {
|
|
124
|
+
const resolve = this.viewResolve;
|
|
125
|
+
this.viewResolve = null;
|
|
126
|
+
this.viewRefresh = null;
|
|
127
|
+
this.viewEsc = '';
|
|
128
|
+
this.viewRefreshing = false;
|
|
129
|
+
if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
|
|
130
|
+
this.selectEscapeTimer = null;
|
|
131
|
+
ui.closePanel();
|
|
132
|
+
resolve?.();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private closePalette(): void {
|
|
136
|
+
if (this.paletteOpen) ui.closeMenu();
|
|
137
|
+
this.paletteOpen = false;
|
|
138
|
+
if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
|
|
139
|
+
this.selectEscapeTimer = null;
|
|
140
|
+
this.esc = '';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
constructor(handlers: LineInputHandlers) {
|
|
144
|
+
this.handlers = handlers;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Enable the editor; piped stdin falls back to plain readline. */
|
|
148
|
+
start(): void {
|
|
149
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
150
|
+
this.fallback = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
151
|
+
this.fallback.on('line', (line) => {
|
|
152
|
+
if (this.questionResolve) {
|
|
153
|
+
this.answerQuestion(line);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.handlers.onLine(line);
|
|
157
|
+
});
|
|
158
|
+
this.fallback.on('SIGINT', () => {
|
|
159
|
+
if (this.questionResolve) this.answerQuestion('n');
|
|
160
|
+
this.handlers.onInterrupt();
|
|
161
|
+
});
|
|
162
|
+
this.fallback.on('close', () => {
|
|
163
|
+
if (this.questionResolve) this.answerQuestion('n');
|
|
164
|
+
this.handlers.onEof();
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
process.stdin.setRawMode(true);
|
|
169
|
+
process.stdin.resume();
|
|
170
|
+
process.stdin.on('data', this.onData);
|
|
171
|
+
process.on('SIGTERM', this.onSigterm);
|
|
172
|
+
this.redraw();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
stop(): void {
|
|
176
|
+
if (this.stopped) return;
|
|
177
|
+
this.stopped = true;
|
|
178
|
+
if (this.navigationTimer) clearTimeout(this.navigationTimer);
|
|
179
|
+
this.navigationTimer = null;
|
|
180
|
+
this.navigation = '';
|
|
181
|
+
this.closePalette();
|
|
182
|
+
if (this.viewResolve) this.closeView();
|
|
183
|
+
ui.clearComposer();
|
|
184
|
+
if (this.selectResolve) this.selectFinish(null);
|
|
185
|
+
ui.closeDock();
|
|
186
|
+
if (this.fallback) {
|
|
187
|
+
this.fallback.close();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
process.stdin.off('data', this.onData);
|
|
191
|
+
process.off('SIGTERM', this.onSigterm);
|
|
192
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
193
|
+
process.stdin.pause();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Stop and fire onEof exactly once (the /exit and Ctrl+D path). */
|
|
197
|
+
close(): void {
|
|
198
|
+
if (this.stopped) return;
|
|
199
|
+
if (this.fallback) {
|
|
200
|
+
this.fallback.close(); // its 'close' handler fires onEof
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.stop();
|
|
204
|
+
this.handlers.onEof();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Pause normal editing during command/clipboard loading; interrupts stay responsive. */
|
|
208
|
+
pause(): void {
|
|
209
|
+
this.paused = true;
|
|
210
|
+
ui.clearComposer();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
resume(): void {
|
|
214
|
+
this.paused = false;
|
|
215
|
+
this.redraw();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Live drafting during agent turns; submitted steering renders at a safe boundary. */
|
|
219
|
+
setWorking(working: boolean): void {
|
|
220
|
+
this.working = working;
|
|
221
|
+
this.redraw();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Release an open command panel/picker before an agent permission question. */
|
|
225
|
+
cancelControls(): void {
|
|
226
|
+
if (this.viewResolve) this.closeView();
|
|
227
|
+
if (this.selectResolve) this.selectFinish(null);
|
|
228
|
+
if (this.questionResolve) this.answerQuestion('n');
|
|
229
|
+
this.closePalette();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Put unsent steering back in the editor after cancellation/error/round limit. */
|
|
233
|
+
restoreDraft(lines: string[]): void {
|
|
234
|
+
if (!lines.length) return;
|
|
235
|
+
const prefix = lines.join(' ') + (this.buffer ? ' ' : '');
|
|
236
|
+
this.buffer = prefix + this.buffer;
|
|
237
|
+
this.cursor += prefix.length;
|
|
238
|
+
this.redraw();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private answerQuestion(answer: string): void {
|
|
242
|
+
const resolve = this.questionResolve;
|
|
243
|
+
this.questionResolve = null;
|
|
244
|
+
ui.closeApproval();
|
|
245
|
+
this.echoQuestion = true;
|
|
246
|
+
if (this.questionDraft) {
|
|
247
|
+
const draft = this.questionDraft;
|
|
248
|
+
this.buffer = draft.buffer;
|
|
249
|
+
this.cursor = draft.cursor;
|
|
250
|
+
this.histIndex = draft.histIndex;
|
|
251
|
+
this.prompt = draft.prompt;
|
|
252
|
+
this.questionDraft = null;
|
|
253
|
+
}
|
|
254
|
+
this.redraw();
|
|
255
|
+
resolve?.(answer);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** One-shot question mode temporarily replaces, then restores, the current draft. */
|
|
259
|
+
question(prompt: string, echoAnswer = true): Promise<string> {
|
|
260
|
+
this.questionDraft = { buffer: this.buffer, cursor: this.cursor, histIndex: this.histIndex, prompt: this.prompt };
|
|
261
|
+
this.echoQuestion = echoAnswer;
|
|
262
|
+
this.prompt = prompt;
|
|
263
|
+
this.buffer = '';
|
|
264
|
+
this.cursor = 0;
|
|
265
|
+
return new Promise((resolve) => {
|
|
266
|
+
this.questionResolve = resolve;
|
|
267
|
+
this.redraw();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Interactive picker: arrow-key menu in a TTY, numbered list in a pipe. Resolves to
|
|
272
|
+
* the chosen index (0-based) or null when cancelled. `current` pre-highlights a row. */
|
|
273
|
+
select(items: string[], current = 0): Promise<number | null> {
|
|
274
|
+
if (items.length === 0) return Promise.resolve(null);
|
|
275
|
+
if (this.fallback) {
|
|
276
|
+
const width = String(items.length).length;
|
|
277
|
+
items.forEach((label, i) => ui.say(`${String(i + 1).padStart(width)}. ${label}`));
|
|
278
|
+
return this.question('pick a number (Enter to cancel): ').then((answer) => {
|
|
279
|
+
const n = Number(answer);
|
|
280
|
+
return Number.isInteger(n) && n >= 1 && n <= items.length ? n - 1 : null;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
this.closePalette();
|
|
284
|
+
this.selectItems = items;
|
|
285
|
+
this.selectIndex = Math.max(0, Math.min(current, items.length - 1));
|
|
286
|
+
this.selectTop = 0;
|
|
287
|
+
this.selectEsc = '';
|
|
288
|
+
ui.clearLine(); // the menu replaces the prompt line
|
|
289
|
+
this.menuShown = false;
|
|
290
|
+
this.renderMenu();
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
this.selectResolve = resolve;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
setModel(model: string): void {
|
|
297
|
+
this.model = model;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Re-render prompt + buffer (called after output lands on the terminal). */
|
|
301
|
+
redraw(): void {
|
|
302
|
+
if (this.stopped || this.paused || this.fallback || this.selectItems || this.viewResolve) return;
|
|
303
|
+
if (this.questionResolve) {
|
|
304
|
+
if (!ui.setApprovalChoice(this.buffer, this.cursor)) ui.line(this.prompt, this.buffer, this.cursor);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
if (this.buffer !== this.paletteText) {
|
|
308
|
+
this.paletteText = this.buffer;
|
|
309
|
+
this.paletteIndex = 0;
|
|
310
|
+
this.paletteDismissed = false;
|
|
311
|
+
}
|
|
312
|
+
const commands = this.paletteDismissed ? [] : matchingCommands(this.buffer);
|
|
313
|
+
if (commands.length) {
|
|
314
|
+
this.paletteIndex = Math.min(this.paletteIndex, commands.length - 1);
|
|
315
|
+
this.paletteOpen = ui.menu(commands.map((c) => `${c.id.padEnd(9)} ${c.description}`), this.paletteIndex, 0,
|
|
316
|
+
() => { this.paletteDismissed = true; this.closePalette(); },
|
|
317
|
+
'↑/↓ choose · tab complete · enter run · esc close');
|
|
318
|
+
} else this.closePalette();
|
|
319
|
+
ui.composer(this.model, this.prompt, this.buffer, this.cursor);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private setLine(text: string): void {
|
|
324
|
+
this.buffer = text;
|
|
325
|
+
this.cursor = text.length;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private submit(text: string): void {
|
|
329
|
+
this.closePalette();
|
|
330
|
+
if (this.working && !this.questionResolve) ui.clearComposer(); // queued steering renders at the next safe boundary
|
|
331
|
+
else if (text.trimStart().startsWith('/') && !this.questionResolve && process.stdout.isTTY) ui.clearComposer();
|
|
332
|
+
else if ((!this.questionResolve || this.echoQuestion) && !ui.hasApproval()) ui.submitInput(this.prompt, text);
|
|
333
|
+
this.buffer = '';
|
|
334
|
+
this.cursor = 0;
|
|
335
|
+
this.prompt = violet('❯ ');
|
|
336
|
+
if (this.questionResolve) {
|
|
337
|
+
this.answerQuestion(text);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (text !== '' && this.history.at(-1) !== text) {
|
|
341
|
+
this.history.push(text);
|
|
342
|
+
}
|
|
343
|
+
this.histIndex = this.history.length;
|
|
344
|
+
this.handlers.onLine(text);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private onData = (chunk: Buffer): void => {
|
|
348
|
+
for (const c of this.decoder.write(chunk)) {
|
|
349
|
+
if (this.stopped) return;
|
|
350
|
+
if (this.pasting) {
|
|
351
|
+
this.pasteBuf += c;
|
|
352
|
+
if (this.pasteBuf.endsWith(PASTE_END)) {
|
|
353
|
+
this.pasteBuf = this.pasteBuf.slice(0, -PASTE_END.length);
|
|
354
|
+
this.deliver(PASTE_END);
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (c === '\x03' || c === '\x04') {
|
|
359
|
+
if (this.navigationTimer) clearTimeout(this.navigationTimer);
|
|
360
|
+
this.navigationTimer = null;
|
|
361
|
+
this.navigation = '';
|
|
362
|
+
this.deliver(c);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (this.navigation || c === '\x1b') {
|
|
366
|
+
this.navigation += c;
|
|
367
|
+
if (this.navigationTimer) clearTimeout(this.navigationTimer);
|
|
368
|
+
this.navigationTimer = null;
|
|
369
|
+
if (/^\x1b\[[\d;<?]*[A-Za-z~]$/.test(this.navigation) || this.navigation.length > 80 ||
|
|
370
|
+
this.navigation.length > 1 && !this.navigation.startsWith('\x1b[')) {
|
|
371
|
+
const sequence = this.navigation;
|
|
372
|
+
this.navigation = '';
|
|
373
|
+
if (!this.scrollKey(sequence)) this.deliver(sequence);
|
|
374
|
+
} else {
|
|
375
|
+
this.navigationTimer = setTimeout(() => {
|
|
376
|
+
const sequence = this.navigation;
|
|
377
|
+
this.navigation = '';
|
|
378
|
+
this.navigationTimer = null;
|
|
379
|
+
if (sequence === '\x1b' && !this.paused) {
|
|
380
|
+
if (this.viewResolve) this.closeView();
|
|
381
|
+
else if (this.selectResolve) this.selectFinish(null);
|
|
382
|
+
else if (this.questionResolve) this.answerQuestion('n');
|
|
383
|
+
else {
|
|
384
|
+
this.paletteDismissed = true;
|
|
385
|
+
this.closePalette();
|
|
386
|
+
this.redraw();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}, 100);
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
this.deliver(c);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
private deliver(chars: string): void {
|
|
398
|
+
if (this.stopped) return;
|
|
399
|
+
if (this.paused) {
|
|
400
|
+
if (chars.includes('\x04')) this.close();
|
|
401
|
+
else if (chars.includes('\x03')) this.handlers.onInterrupt();
|
|
402
|
+
} else this.handle(chars);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private scrollKey(sequence: string): boolean {
|
|
406
|
+
const mouse = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(sequence);
|
|
407
|
+
const page = sequence === '\x1b[5~' ? -1 : sequence === '\x1b[6~' ? 1 : 0;
|
|
408
|
+
if (!mouse && !page) return false;
|
|
409
|
+
const button = mouse ? Number(mouse[1]) : 0;
|
|
410
|
+
if (mouse && (button & 64) === 0) return true; // discard click reports
|
|
411
|
+
const direction = page || ((button & 1) === 0 ? -1 : 1);
|
|
412
|
+
const delta = direction * (page ? 8 : 3);
|
|
413
|
+
if (this.viewResolve) ui.scrollPanel(delta);
|
|
414
|
+
else if (this.selectItems) this.selectMove(delta);
|
|
415
|
+
else if (this.paletteOpen) {
|
|
416
|
+
this.paletteIndex = Math.max(0, Math.min(matchingCommands(this.buffer).length - 1, this.paletteIndex + direction));
|
|
417
|
+
this.redraw();
|
|
418
|
+
} else if (this.questionResolve && ui.hasApproval()) ui.scrollApproval(delta);
|
|
419
|
+
else ui.scrollChat(-delta);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private onSigterm = (): void => {
|
|
424
|
+
this.stop();
|
|
425
|
+
process.exit(0);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
private handle(chars: string): void {
|
|
429
|
+
if (this.viewResolve) {
|
|
430
|
+
this.handleView(chars);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (this.selectResolve) {
|
|
434
|
+
this.handleSelect(chars);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
let i = 0;
|
|
438
|
+
while (i < chars.length) {
|
|
439
|
+
const c = chars[i];
|
|
440
|
+
if (this.pasting) {
|
|
441
|
+
const end = chars.indexOf(PASTE_END, i);
|
|
442
|
+
if (end === -1) {
|
|
443
|
+
this.pasteBuf += chars.slice(i);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this.pasteBuf += chars.slice(i, end);
|
|
447
|
+
i = end + PASTE_END.length;
|
|
448
|
+
this.pasting = false;
|
|
449
|
+
const pasted = this.pasteBuf;
|
|
450
|
+
this.pasteBuf = '';
|
|
451
|
+
if (pasted.includes('\n')) {
|
|
452
|
+
if (pasted.trimEnd() !== '') this.setLine(pasted.replace(/\n/g, ' ').trimEnd());
|
|
453
|
+
// A trailing-newline paste submits as one turn (bracketed-paste contract).
|
|
454
|
+
if (pasted.endsWith('\n')) {
|
|
455
|
+
this.submit(this.buffer);
|
|
456
|
+
this.redraw();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.redraw();
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
this.insert(pasted);
|
|
463
|
+
this.redraw();
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (this.esc) {
|
|
467
|
+
if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
|
|
468
|
+
this.selectEscapeTimer = null;
|
|
469
|
+
this.esc += c;
|
|
470
|
+
if (this.escIsComplete()) {
|
|
471
|
+
this.handleEscape(this.esc);
|
|
472
|
+
this.esc = '';
|
|
473
|
+
}
|
|
474
|
+
i++;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (c === '\x1b') {
|
|
478
|
+
this.esc = c;
|
|
479
|
+
if (this.paletteOpen) {
|
|
480
|
+
this.selectEscapeTimer = setTimeout(() => {
|
|
481
|
+
this.esc = '';
|
|
482
|
+
this.paletteDismissed = true;
|
|
483
|
+
this.closePalette();
|
|
484
|
+
this.redraw();
|
|
485
|
+
}, 100);
|
|
486
|
+
}
|
|
487
|
+
i++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (!this.questionResolve && (c === '\x16' || c === '\x18')) {
|
|
491
|
+
this.closePalette();
|
|
492
|
+
this.paletteDismissed = true;
|
|
493
|
+
if (c === '\x16') this.handlers.onPasteImage?.();
|
|
494
|
+
else this.handlers.onClearImages?.();
|
|
495
|
+
i++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (c === '\t' && this.paletteOpen) {
|
|
499
|
+
const chosen = matchingCommands(this.buffer)[this.paletteIndex];
|
|
500
|
+
if (chosen) this.setLine(chosen.id + ' ');
|
|
501
|
+
this.redraw();
|
|
502
|
+
i++;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (c === '\r' || c === '\n') {
|
|
506
|
+
const chosen = this.paletteOpen ? matchingCommands(this.buffer)[this.paletteIndex] : undefined;
|
|
507
|
+
this.submit(chosen?.id ?? this.buffer);
|
|
508
|
+
i++;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (c === '\x03') {
|
|
512
|
+
if (!this.working || this.questionResolve) this.setLine('');
|
|
513
|
+
this.redraw();
|
|
514
|
+
if (this.questionResolve) this.answerQuestion('n'); // cancellation must never approve a write
|
|
515
|
+
this.handlers.onInterrupt();
|
|
516
|
+
i++;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (c === '\x04') {
|
|
520
|
+
if (this.buffer === '' || this.questionResolve) {
|
|
521
|
+
if (this.questionResolve) this.answerQuestion('n');
|
|
522
|
+
this.close();
|
|
523
|
+
}
|
|
524
|
+
i++;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const result = editLine(c, { buffer: this.buffer, cursor: this.cursor });
|
|
528
|
+
this.buffer = result.buffer;
|
|
529
|
+
this.cursor = result.cursor;
|
|
530
|
+
if (result.submit !== undefined) this.submit(result.submit);
|
|
531
|
+
else this.redraw();
|
|
532
|
+
i++;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private insert(text: string): void {
|
|
537
|
+
this.buffer = this.buffer.slice(0, this.cursor) + text + this.buffer.slice(this.cursor);
|
|
538
|
+
this.cursor += text.length;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private escIsComplete(): boolean {
|
|
542
|
+
const e = this.esc;
|
|
543
|
+
if (e === PASTE_START || e === PASTE_END) return true;
|
|
544
|
+
if (!e.startsWith('\x1b[')) return e.length >= 2;
|
|
545
|
+
const last = e.at(-1);
|
|
546
|
+
return last === '~' || last === 'A' || last === 'B' || last === 'C' || last === 'D' || last === 'H' || last === 'F';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private handleEscape(seq: string): void {
|
|
550
|
+
if (this.paletteOpen && (seq === '\x1b[A' || seq === '\x1b[B')) {
|
|
551
|
+
const delta = seq === '\x1b[A' ? -1 : 1;
|
|
552
|
+
this.paletteIndex = Math.max(0, Math.min(matchingCommands(this.buffer).length - 1, this.paletteIndex + delta));
|
|
553
|
+
this.redraw();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (seq === PASTE_START) {
|
|
557
|
+
this.pasting = true;
|
|
558
|
+
this.pasteBuf = '';
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (seq === PASTE_END) {
|
|
562
|
+
this.pasting = false;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
switch (seq) {
|
|
566
|
+
case '\x1b[A': // up — history
|
|
567
|
+
if (this.history.length > 0) {
|
|
568
|
+
this.histIndex = Math.max(0, this.histIndex - 1);
|
|
569
|
+
this.setLine(this.history[this.histIndex] ?? '');
|
|
570
|
+
this.redraw();
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
case '\x1b[B': // down — history
|
|
574
|
+
if (this.histIndex < this.history.length) {
|
|
575
|
+
this.histIndex = Math.min(this.history.length, this.histIndex + 1);
|
|
576
|
+
this.setLine(this.history[this.histIndex] ?? '');
|
|
577
|
+
this.redraw();
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
case '\x1b[C': // right
|
|
581
|
+
if (this.cursor < this.buffer.length) {
|
|
582
|
+
this.cursor++;
|
|
583
|
+
this.redraw();
|
|
584
|
+
}
|
|
585
|
+
break;
|
|
586
|
+
case '\x1b[D': // left
|
|
587
|
+
if (this.cursor > 0) {
|
|
588
|
+
this.cursor--;
|
|
589
|
+
this.redraw();
|
|
590
|
+
}
|
|
591
|
+
break;
|
|
592
|
+
case '\x1b[H': // home
|
|
593
|
+
this.cursor = 0;
|
|
594
|
+
this.redraw();
|
|
595
|
+
break;
|
|
596
|
+
case '\x1b[F': // end
|
|
597
|
+
this.cursor = this.buffer.length;
|
|
598
|
+
this.redraw();
|
|
599
|
+
break;
|
|
600
|
+
default:
|
|
601
|
+
break; // unknown sequences ignored
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private handleView(chars: string): void {
|
|
606
|
+
for (const c of chars) {
|
|
607
|
+
if (this.viewEsc) {
|
|
608
|
+
if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
|
|
609
|
+
this.selectEscapeTimer = null;
|
|
610
|
+
this.viewEsc += c;
|
|
611
|
+
if (this.viewEsc === '\x1b[A' || this.viewEsc === '\x1b[B') {
|
|
612
|
+
ui.scrollPanel(this.viewEsc === '\x1b[A' ? -1 : 1);
|
|
613
|
+
this.viewEsc = '';
|
|
614
|
+
} else if (!this.viewEsc.startsWith('\x1b[') || this.viewEsc.length > 3) this.viewEsc = '';
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (c === '\x1b') {
|
|
618
|
+
this.viewEsc = c;
|
|
619
|
+
this.selectEscapeTimer = setTimeout(() => this.closeView(), 100);
|
|
620
|
+
} else if (c === '\r' || c === '\n' || c === '\x03') {
|
|
621
|
+
this.closeView();
|
|
622
|
+
return;
|
|
623
|
+
} else if (c === '\x04') {
|
|
624
|
+
this.close();
|
|
625
|
+
return;
|
|
626
|
+
} else if (c.toLowerCase() === 'r' && this.viewRefresh && !this.viewRefreshing) {
|
|
627
|
+
this.viewRefreshing = true;
|
|
628
|
+
const view = this.viewResolve;
|
|
629
|
+
void this.viewRefresh()
|
|
630
|
+
.then((lines) => { if (this.viewResolve === view) this.updateView(lines); })
|
|
631
|
+
.catch(() => { if (this.viewResolve === view) this.updateView(['Usage unavailable — r to retry']); })
|
|
632
|
+
.finally(() => { if (this.viewResolve === view) this.viewRefreshing = false; });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Select-mode keys: ↑/↓ (and j/k) move, Enter confirms, Ctrl+C cancels. */
|
|
638
|
+
private handleSelect(chars: string): void {
|
|
639
|
+
for (const c of chars) {
|
|
640
|
+
if (this.selectEsc) {
|
|
641
|
+
if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
|
|
642
|
+
this.selectEscapeTimer = null;
|
|
643
|
+
this.selectEsc += c;
|
|
644
|
+
if (this.selectEsc === '\x1b[A') {
|
|
645
|
+
this.selectMove(-1);
|
|
646
|
+
this.selectEsc = '';
|
|
647
|
+
} else if (this.selectEsc === '\x1b[B') {
|
|
648
|
+
this.selectMove(1);
|
|
649
|
+
this.selectEsc = '';
|
|
650
|
+
} else if (!this.selectEsc.startsWith('\x1b[') || this.selectEsc.length > 3) {
|
|
651
|
+
this.selectEsc = ''; // stray escape — ignore
|
|
652
|
+
}
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (c === '\x1b') {
|
|
656
|
+
this.selectEsc = c;
|
|
657
|
+
this.selectEscapeTimer = setTimeout(() => this.selectFinish(null), 100);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (c === '\r' || c === '\n') {
|
|
661
|
+
this.selectFinish(this.selectIndex);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (c === '\x03' || c === '\x04') {
|
|
665
|
+
this.selectFinish(null);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (c === 'j' || c === 'J') this.selectMove(1);
|
|
669
|
+
else if (c === 'k' || c === 'K') this.selectMove(-1);
|
|
670
|
+
// other keys are ignored while the menu is open
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private selectMove(delta: number): void {
|
|
675
|
+
if (this.selectItems === null) return;
|
|
676
|
+
const n = this.selectItems.length;
|
|
677
|
+
this.selectIndex = Math.max(0, Math.min(n - 1, this.selectIndex + delta));
|
|
678
|
+
this.renderMenu();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private selectFinish(result: number | null): void {
|
|
682
|
+
const resolve = this.selectResolve;
|
|
683
|
+
const n = this.selectItems?.length ?? 0;
|
|
684
|
+
this.selectResolve = null;
|
|
685
|
+
this.selectItems = null;
|
|
686
|
+
this.selectEsc = '';
|
|
687
|
+
this.selectIndex = 0;
|
|
688
|
+
this.selectTop = 0;
|
|
689
|
+
if (this.selectEscapeTimer) clearTimeout(this.selectEscapeTimer);
|
|
690
|
+
this.selectEscapeTimer = null;
|
|
691
|
+
if (this.dockMenu) ui.closeMenu();
|
|
692
|
+
if (this.menuShown && !this.dockMenu && n > 0) {
|
|
693
|
+
const total = Math.min(n, MENU_HEIGHT) + 1; // items + hint line
|
|
694
|
+
process.stdout.write(`\x1b[${total}A\r\x1b[J`); // erase the menu in place
|
|
695
|
+
}
|
|
696
|
+
this.menuShown = false;
|
|
697
|
+
this.dockMenu = false;
|
|
698
|
+
resolve?.(result);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Draw the menu; re-renders in place by rewinding `total` lines when already shown. */
|
|
702
|
+
private renderMenu(): void {
|
|
703
|
+
if (this.selectItems === null) return;
|
|
704
|
+
const n = this.selectItems.length;
|
|
705
|
+
if (ui.menu(this.selectItems, this.selectIndex, this.selectTop, () => this.selectFinish(null))) {
|
|
706
|
+
this.dockMenu = true;
|
|
707
|
+
this.menuShown = true;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const height = Math.min(n, MENU_HEIGHT);
|
|
711
|
+
if (this.selectIndex < this.selectTop) this.selectTop = this.selectIndex;
|
|
712
|
+
if (this.selectIndex >= this.selectTop + height) this.selectTop = this.selectIndex - height + 1;
|
|
713
|
+
const total = height + 1;
|
|
714
|
+
if (this.menuShown) process.stdout.write(`\x1b[${total}A`);
|
|
715
|
+
for (let i = 0; i < height; i++) {
|
|
716
|
+
process.stdout.write('\r\x1b[K' + this.renderMenuItem(this.selectTop + i) + '\n');
|
|
717
|
+
}
|
|
718
|
+
process.stdout.write('\r\x1b[K' + dim(`↑/↓ move · enter select · ctrl+c cancel (${n} options)`) + '\n');
|
|
719
|
+
this.menuShown = true;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private renderMenuItem(i: number): string {
|
|
723
|
+
const label = this.selectItems![i];
|
|
724
|
+
return i === this.selectIndex ? `${violet('›')} ${bold(label)}` : `${dim(' ' + label)}`;
|
|
725
|
+
}
|
|
726
|
+
}
|