agent-termo 1.0.0 → 1.0.2
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 +30 -11
- package/dist/agents/index.d.ts +5 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +86 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +97 -164
- package/dist/cli.js.map +4 -4
- package/dist/diff/index.d.ts +9 -0
- package/dist/diff/index.d.ts.map +1 -0
- package/dist/diff/index.js +213 -0
- package/dist/diff/index.js.map +1 -0
- package/dist/llm/client.d.ts +39 -0
- package/dist/llm/client.d.ts.map +1 -0
- package/dist/llm/client.js +188 -0
- package/dist/llm/client.js.map +1 -0
- package/dist/llm/models.d.ts +19 -0
- package/dist/llm/models.d.ts.map +1 -0
- package/dist/llm/models.js +45 -0
- package/dist/llm/models.js.map +1 -0
- package/dist/llm/tools.d.ts +3 -0
- package/dist/llm/tools.d.ts.map +1 -0
- package/dist/llm/tools.js +204 -0
- package/dist/llm/tools.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +91 -140
- package/dist/main.js.map +4 -4
- package/dist/memory/index.d.ts +10 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +117 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +334 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/types/index.d.ts +119 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/ui/index.d.ts +47 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +321 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/screen.d.ts +59 -0
- package/dist/ui/screen.d.ts.map +1 -0
- package/dist/ui/screen.js +629 -0
- package/dist/ui/screen.js.map +1 -0
- package/dist/utils/ansi.d.ts +62 -0
- package/dist/utils/ansi.d.ts.map +1 -0
- package/dist/utils/ansi.js +116 -0
- package/dist/utils/ansi.js.map +1 -0
- package/dist/utils/config.d.ts +9 -0
- package/dist/utils/config.d.ts.map +1 -0
- package/dist/utils/config.js +120 -0
- package/dist/utils/config.js.map +1 -0
- package/package.json +2 -2
- package/src/cli.ts +91 -12
- package/src/llm/client.ts +24 -0
- package/src/llm/models.ts +55 -0
- package/src/main.ts +201 -75
- package/src/ui/index.ts +256 -389
- package/src/ui/screen.ts +698 -0
- package/src/utils/config.ts +62 -2
package/src/ui/screen.ts
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Screen layout + raw keyboard input
|
|
3
|
+
//
|
|
4
|
+
// Claude Code-style layout: output scrolls in a region at the top of the
|
|
5
|
+
// terminal while a bordered input box and a status line stay pinned to the
|
|
6
|
+
// bottom. Implemented with a DECSTBM scroll region, so every console.log /
|
|
7
|
+
// process.stdout.write elsewhere in the app lands in the scrolling region.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import Chalk from 'chalk';
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
|
|
13
|
+
const c: typeof Chalk = Chalk as unknown as typeof Chalk;
|
|
14
|
+
const out = process.stdout;
|
|
15
|
+
|
|
16
|
+
const CSI = '\x1b[';
|
|
17
|
+
const SAVE = '\x1b7'; // DECSC — save cursor
|
|
18
|
+
const RESTORE = '\x1b8'; // DECRC — restore cursor
|
|
19
|
+
|
|
20
|
+
/** Rows pinned to the bottom: top border, input row, bottom border, status row. */
|
|
21
|
+
const CHROME_ROWS = 4;
|
|
22
|
+
const MIN_ROWS = 12;
|
|
23
|
+
const MIN_COLS = 40;
|
|
24
|
+
const PLACEHOLDER = 'Ask anything… or type / for commands';
|
|
25
|
+
|
|
26
|
+
export interface CommandInfo {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const S = {
|
|
32
|
+
active: false,
|
|
33
|
+
editing: false,
|
|
34
|
+
/** False when the output position saved at the start of editing is stale (resize). */
|
|
35
|
+
savedValid: true,
|
|
36
|
+
rows: 0,
|
|
37
|
+
model: '',
|
|
38
|
+
project: '',
|
|
39
|
+
commands: [] as CommandInfo[],
|
|
40
|
+
buffer: '',
|
|
41
|
+
cursor: 0,
|
|
42
|
+
offset: 0,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function setCommands(commands: CommandInfo[]): void {
|
|
46
|
+
S.commands = commands;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dims(): { rows: number; cols: number } {
|
|
50
|
+
return { rows: out.rows || 24, cols: out.columns || 80 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function terminalWidth(): number {
|
|
54
|
+
return out.columns || 80;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clipText(text: string, max: number): string {
|
|
58
|
+
if (max <= 0) return '';
|
|
59
|
+
return text.length <= max ? text : text.slice(0, Math.max(0, max - 1)) + '…';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shortPath(p: string): string {
|
|
63
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
64
|
+
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function layoutSupported(): boolean {
|
|
68
|
+
return Boolean(
|
|
69
|
+
out.isTTY && process.stdin.isTTY && (out.rows ?? 0) >= MIN_ROWS && (out.columns ?? 0) >= MIN_COLS,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Bottom chrome (input box + status line)
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
function statusLine(max: number): string {
|
|
78
|
+
// While typing a slash command, the status line shows matching commands.
|
|
79
|
+
if (S.editing && S.buffer.startsWith('/') && !S.buffer.includes(' ')) {
|
|
80
|
+
const typed = S.buffer.toLowerCase();
|
|
81
|
+
const hits = S.commands.filter((cmd) => cmd.name.startsWith(typed));
|
|
82
|
+
const only = hits[0];
|
|
83
|
+
if (hits.length === 1 && only) {
|
|
84
|
+
return ' ' + c.cyan(only.name) + c.dim(clipText(' — ' + only.description, max - only.name.length - 1));
|
|
85
|
+
}
|
|
86
|
+
if (hits.length > 1) {
|
|
87
|
+
return c.cyan(clipText(' ' + hits.map((h) => h.name).join(' '), max));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const hint = '/help · /model · ctrl+c to exit';
|
|
92
|
+
const modelPart = clipText(' ' + S.model, max);
|
|
93
|
+
const roomWithHint = max - modelPart.length - hint.length - 2;
|
|
94
|
+
const showHint = roomWithHint >= 15;
|
|
95
|
+
const projPart = clipText(
|
|
96
|
+
' · ' + shortPath(S.project),
|
|
97
|
+
showHint ? roomWithHint : Math.max(0, max - modelPart.length),
|
|
98
|
+
);
|
|
99
|
+
const gap = Math.max(1, max - modelPart.length - projPart.length - hint.length);
|
|
100
|
+
return c.cyan(modelPart) + c.dim(projPart + (showHint ? ' '.repeat(gap) + hint : ''));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function chromeString(): string {
|
|
104
|
+
const { rows, cols } = dims();
|
|
105
|
+
const width = Math.max(1, cols - 6); // '│ ❯ ' + text + ' │'
|
|
106
|
+
|
|
107
|
+
// Keep the cursor inside the visible window of the input line.
|
|
108
|
+
S.offset = Math.min(S.offset, Math.max(0, S.buffer.length - width + 1));
|
|
109
|
+
if (S.cursor < S.offset) S.offset = S.cursor;
|
|
110
|
+
if (S.cursor > S.offset + width - 1) S.offset = S.cursor - width + 1;
|
|
111
|
+
|
|
112
|
+
const edge = S.editing ? c.magenta : c.dim;
|
|
113
|
+
const body = S.buffer
|
|
114
|
+
? S.buffer.slice(S.offset, S.offset + width).padEnd(width)
|
|
115
|
+
: c.dim(clipText(PLACEHOLDER, width).padEnd(width));
|
|
116
|
+
|
|
117
|
+
const top = rows - 3;
|
|
118
|
+
const line = (row: number, text: string): string => `${CSI}${row};1H${CSI}2K${text}`;
|
|
119
|
+
return [
|
|
120
|
+
line(top, edge('╭' + '─'.repeat(cols - 2) + '╮')),
|
|
121
|
+
line(top + 1, edge('│') + ' ' + c.magenta.bold('❯') + ' ' + body + ' ' + edge('│')),
|
|
122
|
+
line(top + 2, edge('╰' + '─'.repeat(cols - 2) + '╯')),
|
|
123
|
+
line(rows, statusLine(cols - 1)),
|
|
124
|
+
].join('');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function inputCursor(): string {
|
|
128
|
+
const { rows } = dims();
|
|
129
|
+
return `${CSI}${rows - 2};${5 + S.cursor - S.offset}H`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function paint(): void {
|
|
133
|
+
if (!S.active) return;
|
|
134
|
+
if (S.editing) {
|
|
135
|
+
// The saved slot holds the output position while editing: don't touch it.
|
|
136
|
+
out.write(CSI + '?25l' + chromeString() + inputCursor() + CSI + '?25h');
|
|
137
|
+
} else {
|
|
138
|
+
out.write(SAVE + chromeString() + RESTORE);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function regionBottom(): number {
|
|
143
|
+
return Math.max(1, dims().rows - CHROME_ROWS);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyRegion(): void {
|
|
147
|
+
out.write(`${CSI}1;${regionBottom()}r`); // also homes the cursor
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function onResize(): void {
|
|
151
|
+
const { rows } = dims();
|
|
152
|
+
if (rows === S.rows) {
|
|
153
|
+
paint();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Height changed: old chrome rows are stale, so start from a clean screen.
|
|
157
|
+
S.rows = rows;
|
|
158
|
+
S.savedValid = false;
|
|
159
|
+
out.write(`${CSI}2J${CSI}H`);
|
|
160
|
+
applyRegion();
|
|
161
|
+
out.write(chromeString() + `${CSI}${regionBottom()};1H`);
|
|
162
|
+
if (S.editing) paint();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// Screen lifecycle
|
|
167
|
+
// =============================================================================
|
|
168
|
+
|
|
169
|
+
export function initScreen(info: { model: string; project: string }): void {
|
|
170
|
+
S.model = info.model;
|
|
171
|
+
S.project = info.project;
|
|
172
|
+
if (S.active || !layoutSupported()) return;
|
|
173
|
+
|
|
174
|
+
S.active = true;
|
|
175
|
+
S.rows = dims().rows;
|
|
176
|
+
out.write(`${CSI}2J${CSI}H`);
|
|
177
|
+
applyRegion();
|
|
178
|
+
out.write(chromeString() + `${CSI}1;1H`);
|
|
179
|
+
out.on('resize', onResize);
|
|
180
|
+
process.on('exit', teardownScreen);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function setScreenStatus(info: { model?: string; project?: string }): void {
|
|
184
|
+
if (info.model !== undefined) S.model = info.model;
|
|
185
|
+
if (info.project !== undefined) S.project = info.project;
|
|
186
|
+
paint();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Clear everything and start output again from the top (used by /clear). */
|
|
190
|
+
export function resetScreen(): void {
|
|
191
|
+
out.write(`${CSI}2J${CSI}H`);
|
|
192
|
+
if (!S.active) return;
|
|
193
|
+
applyRegion();
|
|
194
|
+
out.write(chromeString() + `${CSI}1;1H`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function teardownScreen(): void {
|
|
198
|
+
if (!S.active) return;
|
|
199
|
+
S.active = false;
|
|
200
|
+
|
|
201
|
+
const { rows } = dims();
|
|
202
|
+
const first = Math.max(1, rows - CHROME_ROWS + 1);
|
|
203
|
+
let wipe = '';
|
|
204
|
+
for (let r = first; r <= rows; r++) wipe += `${CSI}${r};1H${CSI}2K`;
|
|
205
|
+
|
|
206
|
+
// Leave the shell prompt right after the last output line.
|
|
207
|
+
const head = S.editing ? '' : SAVE;
|
|
208
|
+
const back = S.editing && !S.savedValid ? `${CSI}${first};1H` : RESTORE;
|
|
209
|
+
out.write(head + CSI + 'r' + wipe + back + CSI + '?25h' + CSI + '?2004l');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// =============================================================================
|
|
213
|
+
// Key parsing
|
|
214
|
+
// =============================================================================
|
|
215
|
+
|
|
216
|
+
export interface Key {
|
|
217
|
+
name: string;
|
|
218
|
+
text?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// eslint-disable-next-line no-control-regex
|
|
222
|
+
const TOKEN_RE = /\x1b\[200~|\x1b\[201~|\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[A-Za-z]|\x1b[\s\S]|[\s\S]/gu;
|
|
223
|
+
|
|
224
|
+
const NAMED: Record<string, string> = {
|
|
225
|
+
'\r': 'enter',
|
|
226
|
+
'\n': 'enter',
|
|
227
|
+
'\x03': 'ctrl+c',
|
|
228
|
+
'\x04': 'ctrl+d',
|
|
229
|
+
'\x7f': 'backspace',
|
|
230
|
+
'\b': 'backspace',
|
|
231
|
+
'\t': 'tab',
|
|
232
|
+
'\x1b': 'escape',
|
|
233
|
+
'\x01': 'home',
|
|
234
|
+
'\x05': 'end',
|
|
235
|
+
'\x15': 'ctrl+u',
|
|
236
|
+
'\x0b': 'ctrl+k',
|
|
237
|
+
'\x17': 'ctrl+w',
|
|
238
|
+
'\x0c': 'ctrl+l',
|
|
239
|
+
'\x1b[A': 'up',
|
|
240
|
+
'\x1bOA': 'up',
|
|
241
|
+
'\x1b[B': 'down',
|
|
242
|
+
'\x1bOB': 'down',
|
|
243
|
+
'\x1b[C': 'right',
|
|
244
|
+
'\x1bOC': 'right',
|
|
245
|
+
'\x1b[D': 'left',
|
|
246
|
+
'\x1bOD': 'left',
|
|
247
|
+
'\x1b[H': 'home',
|
|
248
|
+
'\x1bOH': 'home',
|
|
249
|
+
'\x1b[1~': 'home',
|
|
250
|
+
'\x1b[F': 'end',
|
|
251
|
+
'\x1bOF': 'end',
|
|
252
|
+
'\x1b[4~': 'end',
|
|
253
|
+
'\x1b[3~': 'delete',
|
|
254
|
+
'\x1b[5~': 'pageup',
|
|
255
|
+
'\x1b[6~': 'pagedown',
|
|
256
|
+
'\x1b[1;5D': 'word-left',
|
|
257
|
+
'\x1b[1;3D': 'word-left',
|
|
258
|
+
'\x1bb': 'word-left',
|
|
259
|
+
'\x1b[1;5C': 'word-right',
|
|
260
|
+
'\x1b[1;3C': 'word-right',
|
|
261
|
+
'\x1bf': 'word-right',
|
|
262
|
+
'\x1b\x7f': 'ctrl+w',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Split a stdin chunk into keys. Consecutive printable characters are merged
|
|
267
|
+
* into one `text` key, and bracketed pastes arrive as a single `paste` key.
|
|
268
|
+
* `state.paste` carries an unfinished paste across chunks.
|
|
269
|
+
*/
|
|
270
|
+
export function parseKeys(chunk: string, state: { paste: string | null }): Key[] {
|
|
271
|
+
const keys: Key[] = [];
|
|
272
|
+
let run = '';
|
|
273
|
+
const flush = (): void => {
|
|
274
|
+
if (run) keys.push({ name: 'text', text: run });
|
|
275
|
+
run = '';
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
for (const tok of chunk.match(TOKEN_RE) ?? []) {
|
|
279
|
+
if (tok === '\x1b[200~') {
|
|
280
|
+
flush();
|
|
281
|
+
state.paste = '';
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (tok === '\x1b[201~') {
|
|
285
|
+
flush();
|
|
286
|
+
if (state.paste !== null) keys.push({ name: 'paste', text: state.paste });
|
|
287
|
+
state.paste = null;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (state.paste !== null) {
|
|
291
|
+
state.paste += tok;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const named = NAMED[tok];
|
|
296
|
+
if (named) {
|
|
297
|
+
flush();
|
|
298
|
+
keys.push({ name: named });
|
|
299
|
+
} else if ((tok.codePointAt(0) ?? 0) >= 0x20 && !tok.startsWith('\x1b') && tok !== '\x7f') {
|
|
300
|
+
run += tok;
|
|
301
|
+
} else {
|
|
302
|
+
flush();
|
|
303
|
+
keys.push({ name: 'unknown' });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
flush();
|
|
307
|
+
return keys;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Put stdin in raw mode and call `handler` for every key until the returned
|
|
312
|
+
* function is called, which restores the terminal.
|
|
313
|
+
*/
|
|
314
|
+
export function listenKeys(handler: (key: Key) => void): () => void {
|
|
315
|
+
const stdin = process.stdin;
|
|
316
|
+
const state = { paste: null as string | null };
|
|
317
|
+
const onData = (chunk: string | Buffer): void => {
|
|
318
|
+
for (const key of parseKeys(chunk.toString(), state)) handler(key);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
stdin.setRawMode?.(true);
|
|
322
|
+
stdin.resume();
|
|
323
|
+
stdin.setEncoding('utf8');
|
|
324
|
+
if (out.isTTY) out.write(`${CSI}?2004h`); // bracketed paste
|
|
325
|
+
stdin.on('data', onData);
|
|
326
|
+
|
|
327
|
+
return () => {
|
|
328
|
+
stdin.off('data', onData);
|
|
329
|
+
if (out.isTTY) out.write(`${CSI}?2004l`);
|
|
330
|
+
stdin.setRawMode?.(false);
|
|
331
|
+
stdin.pause();
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// =============================================================================
|
|
336
|
+
// Line editor
|
|
337
|
+
// =============================================================================
|
|
338
|
+
|
|
339
|
+
function wordLeft(s: string, from: number): number {
|
|
340
|
+
let i = from;
|
|
341
|
+
while (i > 0 && s[i - 1] === ' ') i--;
|
|
342
|
+
while (i > 0 && s[i - 1] !== ' ') i--;
|
|
343
|
+
return i;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function wordRight(s: string, from: number): number {
|
|
347
|
+
let i = from;
|
|
348
|
+
while (i < s.length && s[i] === ' ') i++;
|
|
349
|
+
while (i < s.length && s[i] !== ' ') i++;
|
|
350
|
+
return i;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Pasted text is kept on one line: newlines become spaces, controls are dropped. */
|
|
354
|
+
function sanitizePaste(text: string): string {
|
|
355
|
+
return [...text.replace(/\r\n|\r|\n/g, ' ').replace(/\t/g, ' ')]
|
|
356
|
+
.filter((ch) => (ch.codePointAt(0) ?? 0) >= 0x20 && ch !== '\x7f')
|
|
357
|
+
.join('');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function completeCommand(): void {
|
|
361
|
+
if (!S.buffer.startsWith('/') || S.buffer.includes(' ')) return;
|
|
362
|
+
const typed = S.buffer.toLowerCase();
|
|
363
|
+
const names = S.commands.map((cmd) => cmd.name).filter((n) => n.startsWith(typed));
|
|
364
|
+
const first = names[0];
|
|
365
|
+
if (!first) return;
|
|
366
|
+
|
|
367
|
+
let prefix = first;
|
|
368
|
+
for (const n of names) while (!n.startsWith(prefix)) prefix = prefix.slice(0, -1);
|
|
369
|
+
S.buffer = prefix;
|
|
370
|
+
S.cursor = prefix.length;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Cooked-mode fallback for non-TTY stdin and terminals too small for the layout. */
|
|
374
|
+
function plainQuestion(): Promise<string> {
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
377
|
+
rl.once('close', () => resolve('/exit'));
|
|
378
|
+
rl.question('> ', (answer) => {
|
|
379
|
+
rl.removeAllListeners('close');
|
|
380
|
+
rl.close();
|
|
381
|
+
resolve(answer.trim());
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export interface InputController {
|
|
387
|
+
question: (prompt?: string) => Promise<string>;
|
|
388
|
+
close: () => void;
|
|
389
|
+
showPrompt: () => void;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function createInput(): InputController {
|
|
393
|
+
const history: string[] = [];
|
|
394
|
+
let stop: (() => void) | null = null;
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
question(): Promise<string> {
|
|
398
|
+
if (!S.active) return plainQuestion();
|
|
399
|
+
|
|
400
|
+
return new Promise<string>((resolve) => {
|
|
401
|
+
let histIdx = -1;
|
|
402
|
+
let draft = '';
|
|
403
|
+
let done = false;
|
|
404
|
+
|
|
405
|
+
S.editing = true;
|
|
406
|
+
S.savedValid = true;
|
|
407
|
+
S.buffer = '';
|
|
408
|
+
S.cursor = 0;
|
|
409
|
+
S.offset = 0;
|
|
410
|
+
out.write(SAVE); // remember where output continues
|
|
411
|
+
paint();
|
|
412
|
+
|
|
413
|
+
// Return the cursor to the scrolling region and go idle.
|
|
414
|
+
const leave = (): void => {
|
|
415
|
+
done = true;
|
|
416
|
+
stop?.();
|
|
417
|
+
stop = null;
|
|
418
|
+
S.editing = false;
|
|
419
|
+
S.buffer = '';
|
|
420
|
+
S.cursor = 0;
|
|
421
|
+
S.offset = 0;
|
|
422
|
+
out.write(S.savedValid ? RESTORE : `${CSI}${regionBottom()};1H`);
|
|
423
|
+
paint();
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const quit = (): void => {
|
|
427
|
+
leave();
|
|
428
|
+
process.emit('SIGINT'); // lets the REPL print its session summary
|
|
429
|
+
process.exit(0);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const set = (text: string, cursor: number): void => {
|
|
433
|
+
S.buffer = text;
|
|
434
|
+
S.cursor = cursor;
|
|
435
|
+
paint();
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
stop = listenKeys((key) => {
|
|
439
|
+
if (done) return;
|
|
440
|
+
const { buffer, cursor } = S;
|
|
441
|
+
|
|
442
|
+
switch (key.name) {
|
|
443
|
+
case 'enter': {
|
|
444
|
+
const line = buffer.trim();
|
|
445
|
+
if (line && history[0] !== line) history.unshift(line);
|
|
446
|
+
if (history.length > 100) history.pop();
|
|
447
|
+
leave();
|
|
448
|
+
resolve(line);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
case 'ctrl+c':
|
|
452
|
+
if (buffer) {
|
|
453
|
+
histIdx = -1;
|
|
454
|
+
set('', 0);
|
|
455
|
+
} else {
|
|
456
|
+
quit();
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
case 'ctrl+d':
|
|
460
|
+
if (!buffer) quit();
|
|
461
|
+
else set(buffer.slice(0, cursor) + buffer.slice(cursor + 1), cursor);
|
|
462
|
+
return;
|
|
463
|
+
case 'backspace':
|
|
464
|
+
if (cursor > 0) set(buffer.slice(0, cursor - 1) + buffer.slice(cursor), cursor - 1);
|
|
465
|
+
return;
|
|
466
|
+
case 'delete':
|
|
467
|
+
set(buffer.slice(0, cursor) + buffer.slice(cursor + 1), cursor);
|
|
468
|
+
return;
|
|
469
|
+
case 'left':
|
|
470
|
+
set(buffer, Math.max(0, cursor - 1));
|
|
471
|
+
return;
|
|
472
|
+
case 'right':
|
|
473
|
+
set(buffer, Math.min(buffer.length, cursor + 1));
|
|
474
|
+
return;
|
|
475
|
+
case 'home':
|
|
476
|
+
set(buffer, 0);
|
|
477
|
+
return;
|
|
478
|
+
case 'end':
|
|
479
|
+
set(buffer, buffer.length);
|
|
480
|
+
return;
|
|
481
|
+
case 'word-left':
|
|
482
|
+
set(buffer, wordLeft(buffer, cursor));
|
|
483
|
+
return;
|
|
484
|
+
case 'word-right':
|
|
485
|
+
set(buffer, wordRight(buffer, cursor));
|
|
486
|
+
return;
|
|
487
|
+
case 'ctrl+u':
|
|
488
|
+
set(buffer.slice(cursor), 0);
|
|
489
|
+
return;
|
|
490
|
+
case 'ctrl+k':
|
|
491
|
+
set(buffer.slice(0, cursor), cursor);
|
|
492
|
+
return;
|
|
493
|
+
case 'ctrl+w': {
|
|
494
|
+
const j = wordLeft(buffer, cursor);
|
|
495
|
+
set(buffer.slice(0, j) + buffer.slice(cursor), j);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
case 'ctrl+l':
|
|
499
|
+
paint();
|
|
500
|
+
return;
|
|
501
|
+
case 'tab':
|
|
502
|
+
completeCommand();
|
|
503
|
+
paint();
|
|
504
|
+
return;
|
|
505
|
+
case 'up':
|
|
506
|
+
if (histIdx < history.length - 1) {
|
|
507
|
+
if (histIdx === -1) draft = buffer;
|
|
508
|
+
histIdx++;
|
|
509
|
+
const entry = history[histIdx] ?? '';
|
|
510
|
+
set(entry, entry.length);
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
case 'down':
|
|
514
|
+
if (histIdx >= 0) {
|
|
515
|
+
histIdx--;
|
|
516
|
+
const entry = histIdx === -1 ? draft : (history[histIdx] ?? '');
|
|
517
|
+
set(entry, entry.length);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
case 'text':
|
|
521
|
+
case 'paste': {
|
|
522
|
+
const text = key.name === 'paste' ? sanitizePaste(key.text ?? '') : (key.text ?? '');
|
|
523
|
+
if (text) set(buffer.slice(0, cursor) + text + buffer.slice(cursor), cursor + text.length);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
default:
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
close(): void {
|
|
534
|
+
stop?.();
|
|
535
|
+
stop = null;
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
showPrompt(): void {
|
|
539
|
+
paint();
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// =============================================================================
|
|
545
|
+
// Picker (arrow keys + type-to-filter), rendered in the scrolling region
|
|
546
|
+
// =============================================================================
|
|
547
|
+
|
|
548
|
+
export interface PickItem {
|
|
549
|
+
value: string;
|
|
550
|
+
label: string;
|
|
551
|
+
hint?: string;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Let the user choose one item. Resolves with its value, or null when cancelled
|
|
556
|
+
* (or when there is no interactive terminal). With `allowCustom`, a typed value
|
|
557
|
+
* that matches nothing can be chosen as-is.
|
|
558
|
+
*/
|
|
559
|
+
export function pick(opts: {
|
|
560
|
+
title: string;
|
|
561
|
+
items: PickItem[];
|
|
562
|
+
current?: string;
|
|
563
|
+
allowCustom?: boolean;
|
|
564
|
+
}): Promise<string | null> {
|
|
565
|
+
if (!process.stdin.isTTY || !out.isTTY) return Promise.resolve(null);
|
|
566
|
+
|
|
567
|
+
const { rows, cols } = dims();
|
|
568
|
+
const visible = Math.max(3, Math.min(10, rows - (S.active ? CHROME_ROWS : 0) - 5));
|
|
569
|
+
const height = visible + 3; // title, search, items, footer
|
|
570
|
+
const max = cols - 1;
|
|
571
|
+
|
|
572
|
+
let filter = '';
|
|
573
|
+
let top = 0;
|
|
574
|
+
let sel = Math.max(0, opts.items.findIndex((it) => it.value === opts.current));
|
|
575
|
+
|
|
576
|
+
const matches = (): PickItem[] => {
|
|
577
|
+
const words = filter.toLowerCase().split(/\s+/).filter(Boolean);
|
|
578
|
+
const list = opts.items.filter((it) => {
|
|
579
|
+
const hay = (it.label + ' ' + (it.hint ?? '')).toLowerCase();
|
|
580
|
+
return words.every((w) => hay.includes(w));
|
|
581
|
+
});
|
|
582
|
+
// Custom IDs look like "org/name". The entry goes last, so Enter picks the
|
|
583
|
+
// best filtered match and the custom ID is first only when nothing matches.
|
|
584
|
+
const typed = filter.trim();
|
|
585
|
+
if (opts.allowCustom && typed.includes('/') && !opts.items.some((it) => it.value === typed)) {
|
|
586
|
+
list.push({ value: typed, label: typed, hint: 'use as typed' });
|
|
587
|
+
}
|
|
588
|
+
return list;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const frame = (): string[] => {
|
|
592
|
+
const list = matches();
|
|
593
|
+
sel = Math.min(sel, Math.max(0, list.length - 1));
|
|
594
|
+
if (sel < top) top = sel;
|
|
595
|
+
if (sel >= top + visible) top = sel - visible + 1;
|
|
596
|
+
|
|
597
|
+
const lines: string[] = [
|
|
598
|
+
c.bold(clipText(opts.title, max - 16)) + c.dim(` ${list.length} of ${opts.items.length}`),
|
|
599
|
+
c.dim(' search › ') + clipText(filter, max - 14) + c.dim('▏'),
|
|
600
|
+
];
|
|
601
|
+
for (let i = 0; i < visible; i++) {
|
|
602
|
+
const it = list[top + i];
|
|
603
|
+
if (!it) {
|
|
604
|
+
lines.push(i === 0 && list.length === 0 ? c.dim(' no matches') : '');
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const isSel = top + i === sel;
|
|
608
|
+
const isCur = it.value === opts.current;
|
|
609
|
+
const label = clipText(it.label, max - 6);
|
|
610
|
+
const room = max - 3 - label.length - (isCur ? 2 : 0) - 2;
|
|
611
|
+
const hint = it.hint && room > 8 ? ' ' + clipText(it.hint, room) : '';
|
|
612
|
+
lines.push(
|
|
613
|
+
(isSel ? c.magenta(' › ') : ' ') +
|
|
614
|
+
(isSel ? c.magenta.bold(label) : label) +
|
|
615
|
+
(isCur ? c.green(' ✓') : '') +
|
|
616
|
+
c.dim(hint),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
lines.push(c.dim(clipText(' ↑↓ move · enter select · esc cancel · type to filter', max)));
|
|
620
|
+
return lines;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const draw = (first: boolean): void => {
|
|
624
|
+
const body = frame()
|
|
625
|
+
.map((l) => `${CSI}2K${l}\n`)
|
|
626
|
+
.join('');
|
|
627
|
+
out.write((first ? '' : `${CSI}${height}A\r`) + body);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
return new Promise<string | null>((resolve) => {
|
|
631
|
+
let done = false;
|
|
632
|
+
let stop: () => void = () => undefined;
|
|
633
|
+
|
|
634
|
+
const end = (value: string | null): void => {
|
|
635
|
+
done = true;
|
|
636
|
+
stop();
|
|
637
|
+
// Remove the picker lines from the scrolling region.
|
|
638
|
+
out.write(`${CSI}${height}A\r${CSI}${height}M${CSI}?25h`);
|
|
639
|
+
resolve(value);
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
out.write(CSI + '?25l');
|
|
643
|
+
draw(true);
|
|
644
|
+
|
|
645
|
+
stop = listenKeys((key) => {
|
|
646
|
+
if (done) return;
|
|
647
|
+
const list = matches();
|
|
648
|
+
const resetFilter = (): void => {
|
|
649
|
+
sel = 0;
|
|
650
|
+
top = 0;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
switch (key.name) {
|
|
654
|
+
case 'up':
|
|
655
|
+
sel = Math.max(0, sel - 1);
|
|
656
|
+
break;
|
|
657
|
+
case 'down':
|
|
658
|
+
sel = Math.min(Math.max(0, list.length - 1), sel + 1);
|
|
659
|
+
break;
|
|
660
|
+
case 'pageup':
|
|
661
|
+
sel = Math.max(0, sel - visible);
|
|
662
|
+
break;
|
|
663
|
+
case 'pagedown':
|
|
664
|
+
sel = Math.min(Math.max(0, list.length - 1), sel + visible);
|
|
665
|
+
break;
|
|
666
|
+
case 'home':
|
|
667
|
+
sel = 0;
|
|
668
|
+
break;
|
|
669
|
+
case 'end':
|
|
670
|
+
sel = Math.max(0, list.length - 1);
|
|
671
|
+
break;
|
|
672
|
+
case 'backspace':
|
|
673
|
+
filter = filter.slice(0, -1);
|
|
674
|
+
resetFilter();
|
|
675
|
+
break;
|
|
676
|
+
case 'ctrl+u':
|
|
677
|
+
filter = '';
|
|
678
|
+
resetFilter();
|
|
679
|
+
break;
|
|
680
|
+
case 'text':
|
|
681
|
+
case 'paste':
|
|
682
|
+
filter += sanitizePaste(key.text ?? '');
|
|
683
|
+
resetFilter();
|
|
684
|
+
break;
|
|
685
|
+
case 'enter':
|
|
686
|
+
end(list[sel]?.value ?? null);
|
|
687
|
+
return;
|
|
688
|
+
case 'escape':
|
|
689
|
+
case 'ctrl+c':
|
|
690
|
+
end(null);
|
|
691
|
+
return;
|
|
692
|
+
default:
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
draw(false);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
}
|