@devmarketplacenpm/devmp 0.1.1-beta.5
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 +208 -0
- package/bin/devmp.js +245 -0
- package/lib/api.js +231 -0
- package/lib/browser.js +21 -0
- package/lib/checkpoints.js +292 -0
- package/lib/command-runner.js +368 -0
- package/lib/commands.js +597 -0
- package/lib/completion.js +190 -0
- package/lib/config.js +172 -0
- package/lib/diff.js +216 -0
- package/lib/executor.js +208 -0
- package/lib/git.js +39 -0
- package/lib/instructions.js +69 -0
- package/lib/interactive-tunnel.js +420 -0
- package/lib/interactive.js +1269 -0
- package/lib/markdown.js +201 -0
- package/lib/mentions.js +68 -0
- package/lib/prompt.js +140 -0
- package/lib/routes.js +27 -0
- package/lib/session.js +107 -0
- package/lib/status.js +249 -0
- package/lib/tty/ansi.js +171 -0
- package/lib/tty/composer.js +680 -0
- package/lib/tty/screen.js +167 -0
- package/lib/ui.js +174 -0
- package/lib/version.js +134 -0
- package/lib/workspace.js +608 -0
- package/lib/ws-run.js +142 -0
- package/package.json +40 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const readline = require("readline");
|
|
4
|
+
const { color } = require("../ui");
|
|
5
|
+
const { displayWidth, wrap } = require("./ansi");
|
|
6
|
+
|
|
7
|
+
// The composer owns stdin in raw mode for the whole interactive session. That
|
|
8
|
+
// is what lets the user keep typing while a turn is streaming: keystrokes are
|
|
9
|
+
// ours to interpret, so a running turn no longer has to block the prompt.
|
|
10
|
+
//
|
|
11
|
+
// It also owns the live region, because the caret has to be placed inside the
|
|
12
|
+
// input box and only one component can do that coherently. The shell supplies
|
|
13
|
+
// the rows above the box through `statusLines()`.
|
|
14
|
+
|
|
15
|
+
const HISTORY_LIMIT = 100;
|
|
16
|
+
|
|
17
|
+
// Ask the terminal to wrap pasted text in \e[200~ … \e[201~. Without it a
|
|
18
|
+
// pasted stack trace arrives as ordinary keystrokes, every embedded newline
|
|
19
|
+
// reads as Enter, and one paste fires one agent turn per line — nonsense
|
|
20
|
+
// questions, billed individually. Node's readline already decodes the markers
|
|
21
|
+
// into `paste-start` / `paste-end` keypresses.
|
|
22
|
+
const PASTE_ON = "\u001b[?2004h";
|
|
23
|
+
const PASTE_OFF = "\u001b[?2004l";
|
|
24
|
+
|
|
25
|
+
// A pasted file should not push the whole conversation off screen. Past this
|
|
26
|
+
// many rows the box scrolls around the caret instead of growing.
|
|
27
|
+
const MAX_INPUT_ROWS = 12;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Undo everything we did to the terminal, whatever killed us.
|
|
31
|
+
*
|
|
32
|
+
* The shell hides the cursor and turns on bracketed paste. A clean exit undoes
|
|
33
|
+
* both; a crash or a SIGTERM does not, and the user is left with an invisible
|
|
34
|
+
* cursor and `200~` glued to the front of every paste in whatever they run
|
|
35
|
+
* next — until they think to type `reset`. Cheap to guarantee, so guarantee it.
|
|
36
|
+
*/
|
|
37
|
+
function armTerminalRestore(output) {
|
|
38
|
+
let done = false;
|
|
39
|
+
const restore = () => {
|
|
40
|
+
if (done) return;
|
|
41
|
+
done = true;
|
|
42
|
+
try {
|
|
43
|
+
output.write?.(PASTE_OFF);
|
|
44
|
+
output.write?.("\u001b[?25h");
|
|
45
|
+
} catch {
|
|
46
|
+
/* the stream is already gone; nothing left to restore */
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const onSignal = (signal) => {
|
|
51
|
+
restore();
|
|
52
|
+
process.off("exit", restore);
|
|
53
|
+
// Re-raise so the exit code still says what killed us.
|
|
54
|
+
process.kill(process.pid, signal);
|
|
55
|
+
};
|
|
56
|
+
const onSigterm = () => onSignal("SIGTERM");
|
|
57
|
+
const onSighup = () => onSignal("SIGHUP");
|
|
58
|
+
|
|
59
|
+
process.on("exit", restore);
|
|
60
|
+
process.once("SIGTERM", onSigterm);
|
|
61
|
+
process.once("SIGHUP", onSighup);
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
restore();
|
|
65
|
+
process.off("exit", restore);
|
|
66
|
+
process.off("SIGTERM", onSigterm);
|
|
67
|
+
process.off("SIGHUP", onSighup);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function boxRow(inner, width) {
|
|
72
|
+
const pad = Math.max(0, width - 4 - displayWidth(inner));
|
|
73
|
+
return `${color.dim("│")} ${inner}${" ".repeat(pad)} ${color.dim("│")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Show at most `cap` rows, keeping the caret in view. Returns how many rows
|
|
78
|
+
* fell off each end so the caller can say so rather than silently hiding them.
|
|
79
|
+
*/
|
|
80
|
+
function windowRows(rows, caretRow, cap) {
|
|
81
|
+
if (rows.length <= cap) {
|
|
82
|
+
return { rows, offset: 0, hiddenAbove: 0, hiddenBelow: 0 };
|
|
83
|
+
}
|
|
84
|
+
const half = Math.floor(cap / 2);
|
|
85
|
+
const offset = Math.max(0, Math.min(caretRow - half, rows.length - cap));
|
|
86
|
+
return {
|
|
87
|
+
rows: rows.slice(offset, offset + cap),
|
|
88
|
+
offset,
|
|
89
|
+
hiddenAbove: offset,
|
|
90
|
+
hiddenBelow: rows.length - (offset + cap),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Greedy word-wrap that also reports where `caretIndex` lands. Wrapping and
|
|
96
|
+
* caret placement have to come from one pass — computing them separately lets
|
|
97
|
+
* them disagree whenever a word straddles a wrap point, which shows up as a
|
|
98
|
+
* cursor sitting one row away from the character it is actually editing.
|
|
99
|
+
*/
|
|
100
|
+
function layoutInput(value, caretIndex, width) {
|
|
101
|
+
const limit = Math.max(1, width);
|
|
102
|
+
const glyphs = Array.from(value);
|
|
103
|
+
const rows = [];
|
|
104
|
+
let row = "";
|
|
105
|
+
let rowWidth = 0;
|
|
106
|
+
let caretRow = 0;
|
|
107
|
+
let caretCol = 0;
|
|
108
|
+
let placed = false;
|
|
109
|
+
|
|
110
|
+
const flush = () => {
|
|
111
|
+
rows.push(row);
|
|
112
|
+
row = "";
|
|
113
|
+
rowWidth = 0;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < glyphs.length; i += 1) {
|
|
117
|
+
if (i === caretIndex) {
|
|
118
|
+
caretRow = rows.length;
|
|
119
|
+
caretCol = rowWidth;
|
|
120
|
+
placed = true;
|
|
121
|
+
}
|
|
122
|
+
const glyph = glyphs[i];
|
|
123
|
+
if (glyph === "\n") {
|
|
124
|
+
flush();
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const glyphWidth = displayWidth(glyph);
|
|
128
|
+
if (rowWidth + glyphWidth > limit) flush();
|
|
129
|
+
row += glyph;
|
|
130
|
+
rowWidth += glyphWidth;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!placed) {
|
|
134
|
+
caretRow = rows.length;
|
|
135
|
+
caretCol = rowWidth;
|
|
136
|
+
}
|
|
137
|
+
rows.push(row);
|
|
138
|
+
return { rows, caretRow, caretCol };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createComposer({
|
|
142
|
+
screen,
|
|
143
|
+
statusLines = () => [],
|
|
144
|
+
hintLines = () => [],
|
|
145
|
+
placeholder = "Describe a task, ask a question, or /help",
|
|
146
|
+
promptLabel = () => "›",
|
|
147
|
+
onSubmit,
|
|
148
|
+
onCancel,
|
|
149
|
+
onExit,
|
|
150
|
+
onPaste,
|
|
151
|
+
// (value, caretIndex) -> { from, to, insert, options } | null. Async, because
|
|
152
|
+
// completing a path means hitting the filesystem.
|
|
153
|
+
completer,
|
|
154
|
+
// Seeded from disk so up-arrow reaches yesterday's prompts, not just this
|
|
155
|
+
// session's. The shell owns persistence; the composer just reports changes.
|
|
156
|
+
initialHistory = [],
|
|
157
|
+
onHistoryChange,
|
|
158
|
+
// Injectable so the key handling can be driven in tests without putting a
|
|
159
|
+
// real terminal into raw mode.
|
|
160
|
+
input = process.stdin,
|
|
161
|
+
output = process.stdout,
|
|
162
|
+
}) {
|
|
163
|
+
|
|
164
|
+
let chars = [];
|
|
165
|
+
let caret = 0;
|
|
166
|
+
let history = Array.isArray(initialHistory)
|
|
167
|
+
? initialHistory.slice(-HISTORY_LIMIT)
|
|
168
|
+
: [];
|
|
169
|
+
let historyIndex = -1;
|
|
170
|
+
let draft = "";
|
|
171
|
+
let queue = [];
|
|
172
|
+
let busy = false;
|
|
173
|
+
let mode = "input";
|
|
174
|
+
let ask = null;
|
|
175
|
+
let interruptArmed = false;
|
|
176
|
+
let started = false;
|
|
177
|
+
// Set between paste-start and paste-end. While it holds, newlines are text
|
|
178
|
+
// rather than Enter, and redraws are suppressed — a 2,000-character paste is
|
|
179
|
+
// 2,000 keypresses, and repainting on each one takes seconds.
|
|
180
|
+
let pasting = false;
|
|
181
|
+
let pastedLines = 0;
|
|
182
|
+
let completionOptions = [];
|
|
183
|
+
let completing = false;
|
|
184
|
+
let disarmRestore = null;
|
|
185
|
+
|
|
186
|
+
const text = () => chars.join("");
|
|
187
|
+
|
|
188
|
+
/** Rows of the input box, already wrapped to the terminal so each returned
|
|
189
|
+
* line is exactly one terminal row and the caret maps 1:1 onto it. */
|
|
190
|
+
function inputLines() {
|
|
191
|
+
const width = screen.columns;
|
|
192
|
+
const contentWidth = Math.max(8, width - 6);
|
|
193
|
+
const label = promptLabel();
|
|
194
|
+
const value = text();
|
|
195
|
+
|
|
196
|
+
const rows = [];
|
|
197
|
+
let caretRow = 0;
|
|
198
|
+
let caretCol = 0;
|
|
199
|
+
|
|
200
|
+
if (mode === "ask") {
|
|
201
|
+
const question = ask.question;
|
|
202
|
+
for (const row of wrap(question, contentWidth)) rows.push(row);
|
|
203
|
+
const answer = ask.freeText ? value : "";
|
|
204
|
+
const line = `${color.dim(ask.hint)}${answer ? ` ${answer}` : ""}`;
|
|
205
|
+
rows.push(line);
|
|
206
|
+
caretRow = rows.length - 1;
|
|
207
|
+
caretCol =
|
|
208
|
+
displayWidth(ask.hint) + (answer ? 1 + displayWidth(answer) : 0);
|
|
209
|
+
} else if (!value) {
|
|
210
|
+
rows.push(`${color.cyan(label)} ${color.dim(placeholder)}`);
|
|
211
|
+
caretRow = 0;
|
|
212
|
+
caretCol = displayWidth(label) + 1;
|
|
213
|
+
} else {
|
|
214
|
+
const indent = displayWidth(label) + 1;
|
|
215
|
+
const layout = layoutInput(value, caret, contentWidth - indent);
|
|
216
|
+
const cap = Math.max(3, Math.min(MAX_INPUT_ROWS, screen.rows - 8));
|
|
217
|
+
const view = windowRows(layout.rows, layout.caretRow, cap);
|
|
218
|
+
|
|
219
|
+
if (view.hiddenAbove) {
|
|
220
|
+
rows.push(color.dim(` ⋯ ${view.hiddenAbove} line(s) above`));
|
|
221
|
+
}
|
|
222
|
+
caretRow = rows.length + (layout.caretRow - view.offset);
|
|
223
|
+
caretCol = indent + layout.caretCol;
|
|
224
|
+
view.rows.forEach((row, index) => {
|
|
225
|
+
const absolute = view.offset + index;
|
|
226
|
+
const prefix =
|
|
227
|
+
absolute === 0 ? color.cyan(label) : " ".repeat(displayWidth(label));
|
|
228
|
+
rows.push(`${prefix} ${row}`);
|
|
229
|
+
});
|
|
230
|
+
if (view.hiddenBelow) {
|
|
231
|
+
rows.push(color.dim(` ⋯ ${view.hiddenBelow} line(s) below`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const top = color.dim(`╭${"─".repeat(Math.max(2, screen.columns - 2))}╮`);
|
|
236
|
+
const bottom = color.dim(`╰${"─".repeat(Math.max(2, screen.columns - 2))}╯`);
|
|
237
|
+
const boxed = rows.map((row) => boxRow(row, screen.columns));
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
lines: [top, ...boxed, bottom],
|
|
241
|
+
caret: { line: 1 + caretRow, column: 2 + caretCol },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The choices from an ambiguous Tab, laid out across the terminal width. */
|
|
246
|
+
function completionLines() {
|
|
247
|
+
if (!completionOptions.length) return [];
|
|
248
|
+
const width = Math.max(20, screen.columns - 4);
|
|
249
|
+
const cellWidth =
|
|
250
|
+
Math.max(...completionOptions.map((item) => displayWidth(item))) + 2;
|
|
251
|
+
const perRow = Math.max(1, Math.floor(width / cellWidth));
|
|
252
|
+
const lines = [];
|
|
253
|
+
for (let i = 0; i < completionOptions.length; i += perRow) {
|
|
254
|
+
const cells = completionOptions
|
|
255
|
+
.slice(i, i + perRow)
|
|
256
|
+
.map((item) => item + " ".repeat(cellWidth - displayWidth(item)));
|
|
257
|
+
lines.push(` ${color.dim(cells.join("").trimEnd())}`);
|
|
258
|
+
}
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function complete() {
|
|
263
|
+
if (!completer || completing) return;
|
|
264
|
+
completing = true;
|
|
265
|
+
try {
|
|
266
|
+
const result = await completer(text(), caret);
|
|
267
|
+
if (!result) {
|
|
268
|
+
completionOptions = [];
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const insert = Array.from(result.insert ?? "");
|
|
272
|
+
chars = [
|
|
273
|
+
...chars.slice(0, result.from),
|
|
274
|
+
...insert,
|
|
275
|
+
...chars.slice(result.to),
|
|
276
|
+
];
|
|
277
|
+
caret = result.from + insert.length;
|
|
278
|
+
completionOptions = result.options ?? [];
|
|
279
|
+
if (result.truncated) {
|
|
280
|
+
completionOptions = [
|
|
281
|
+
...completionOptions,
|
|
282
|
+
`… +${result.truncated} more`,
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
// A completion that fails is a non-event: never let it take the shell
|
|
287
|
+
// down mid-keystroke.
|
|
288
|
+
completionOptions = [];
|
|
289
|
+
} finally {
|
|
290
|
+
completing = false;
|
|
291
|
+
refresh();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function queueLines() {
|
|
296
|
+
if (queue.length === 0) return [];
|
|
297
|
+
return queue.map(
|
|
298
|
+
(item, index) =>
|
|
299
|
+
` ${color.dim(`${index + 1}.`)} ${color.dim(
|
|
300
|
+
item.length > screen.columns - 12
|
|
301
|
+
? `${item.slice(0, screen.columns - 15)}…`
|
|
302
|
+
: item
|
|
303
|
+
)}`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function refresh() {
|
|
308
|
+
if (!screen.isTty || !started || pasting) return;
|
|
309
|
+
const box = inputLines();
|
|
310
|
+
const status = statusLines();
|
|
311
|
+
const queued = queueLines();
|
|
312
|
+
const head = queued.length
|
|
313
|
+
? [...status, color.dim(` queued (${queued.length}) — esc to clear`), ...queued]
|
|
314
|
+
: status;
|
|
315
|
+
const lines = [...head, ...box.lines, ...completionLines(), ...hintLines()];
|
|
316
|
+
screen.setLive(lines, {
|
|
317
|
+
line: head.length + box.caret.line,
|
|
318
|
+
column: box.caret.column,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function setText(value) {
|
|
323
|
+
chars = Array.from(value);
|
|
324
|
+
caret = chars.length;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function clearInput() {
|
|
328
|
+
chars = [];
|
|
329
|
+
caret = 0;
|
|
330
|
+
historyIndex = -1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function pushHistory(value) {
|
|
334
|
+
if (!value.trim()) return;
|
|
335
|
+
if (history[history.length - 1] === value) return;
|
|
336
|
+
history.push(value);
|
|
337
|
+
if (history.length > HISTORY_LIMIT) history.shift();
|
|
338
|
+
onHistoryChange?.(history.slice());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function submit() {
|
|
342
|
+
const value = text().trim();
|
|
343
|
+
if (!value) return;
|
|
344
|
+
pushHistory(value);
|
|
345
|
+
clearInput();
|
|
346
|
+
if (busy) {
|
|
347
|
+
queue.push(value);
|
|
348
|
+
refresh();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
refresh();
|
|
352
|
+
onSubmit?.(value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function deleteWord() {
|
|
356
|
+
let i = caret;
|
|
357
|
+
while (i > 0 && /\s/.test(chars[i - 1])) i -= 1;
|
|
358
|
+
while (i > 0 && !/\s/.test(chars[i - 1])) i -= 1;
|
|
359
|
+
chars.splice(i, caret - i);
|
|
360
|
+
caret = i;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function handleAskKey(str, key) {
|
|
364
|
+
if (key.ctrl && key.name === "c") {
|
|
365
|
+
const { resolve, defaultValue } = ask;
|
|
366
|
+
ask = null;
|
|
367
|
+
mode = "input";
|
|
368
|
+
clearInput();
|
|
369
|
+
resolve(defaultValue);
|
|
370
|
+
refresh();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (ask.freeText) {
|
|
374
|
+
if (key.name === "return" || key.name === "enter") {
|
|
375
|
+
const answer = text().trim();
|
|
376
|
+
const resolve = ask.resolve;
|
|
377
|
+
clearInput();
|
|
378
|
+
ask = null;
|
|
379
|
+
mode = "input";
|
|
380
|
+
resolve(answer);
|
|
381
|
+
refresh();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
handleEditing(str, key);
|
|
385
|
+
refresh();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const pressed = (str || "").toLowerCase();
|
|
390
|
+
const choice =
|
|
391
|
+
ask.choices[pressed] ??
|
|
392
|
+
(key.name === "return" || key.name === "enter"
|
|
393
|
+
? ask.defaultValue
|
|
394
|
+
: undefined);
|
|
395
|
+
if (choice === undefined) return;
|
|
396
|
+
const resolve = ask.resolve;
|
|
397
|
+
ask = null;
|
|
398
|
+
mode = "input";
|
|
399
|
+
resolve(choice);
|
|
400
|
+
refresh();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function handleEditing(str, key) {
|
|
404
|
+
if (key.name === "backspace") {
|
|
405
|
+
if (caret > 0) {
|
|
406
|
+
chars.splice(caret - 1, 1);
|
|
407
|
+
caret -= 1;
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (key.name === "delete") {
|
|
412
|
+
if (caret < chars.length) chars.splice(caret, 1);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (key.name === "left") {
|
|
416
|
+
caret = Math.max(0, caret - 1);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (key.name === "right") {
|
|
420
|
+
caret = Math.min(chars.length, caret + 1);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (key.ctrl && key.name === "a") {
|
|
424
|
+
caret = 0;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (key.ctrl && key.name === "e") {
|
|
428
|
+
caret = chars.length;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (key.ctrl && key.name === "u") {
|
|
432
|
+
chars.splice(0, caret);
|
|
433
|
+
caret = 0;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (key.ctrl && key.name === "k") {
|
|
437
|
+
chars.splice(caret);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (key.ctrl && key.name === "w") {
|
|
441
|
+
deleteWord();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Ctrl-J inserts a literal newline so a prompt can span lines; Enter alone
|
|
445
|
+
// always submits, which is what people expect from a chat composer.
|
|
446
|
+
if (key.ctrl && key.name === "j") {
|
|
447
|
+
chars.splice(caret, 0, "\n");
|
|
448
|
+
caret += 1;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
insertText(str, key);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function insertText(str, key) {
|
|
455
|
+
if (!str || key.ctrl || key.meta) return;
|
|
456
|
+
for (const char of str) {
|
|
457
|
+
if (char === "\r" || char === "\n") continue;
|
|
458
|
+
chars.splice(caret, 0, char);
|
|
459
|
+
caret += 1;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function onKeypress(str, key) {
|
|
464
|
+
if (!key) return;
|
|
465
|
+
|
|
466
|
+
if (key.name === "paste-start") {
|
|
467
|
+
pasting = true;
|
|
468
|
+
pastedLines = 0;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (key.name === "paste-end") {
|
|
472
|
+
pasting = false;
|
|
473
|
+
// Enter right after a paste should send it, not append a blank line, so
|
|
474
|
+
// the newline count is what the user is told about — not a submit.
|
|
475
|
+
if (pastedLines > 0) onPaste?.(pastedLines + 1);
|
|
476
|
+
refresh();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Inside a paste every key is literal text; none of the editing bindings
|
|
481
|
+
// below should fire, or a stack trace containing "^C" would cancel a turn.
|
|
482
|
+
if (pasting) {
|
|
483
|
+
if (key.name === "return" || key.name === "enter") {
|
|
484
|
+
chars.splice(caret, 0, "\n");
|
|
485
|
+
caret += 1;
|
|
486
|
+
pastedLines += 1;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
insertText(str, key);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (mode === "ask") {
|
|
494
|
+
handleAskKey(str, key);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (key.ctrl && key.name === "c") {
|
|
499
|
+
if (busy) {
|
|
500
|
+
onCancel?.();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (chars.length > 0) {
|
|
504
|
+
clearInput();
|
|
505
|
+
interruptArmed = false;
|
|
506
|
+
refresh();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (interruptArmed) {
|
|
510
|
+
onExit?.();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
interruptArmed = true;
|
|
514
|
+
refresh();
|
|
515
|
+
setTimeout(() => {
|
|
516
|
+
interruptArmed = false;
|
|
517
|
+
refresh();
|
|
518
|
+
}, 2000).unref?.();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
interruptArmed = false;
|
|
523
|
+
|
|
524
|
+
if (key.name === "tab") {
|
|
525
|
+
void complete();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
// Any other key means the choices on screen are stale.
|
|
529
|
+
completionOptions = [];
|
|
530
|
+
|
|
531
|
+
if (key.ctrl && key.name === "d" && chars.length === 0) {
|
|
532
|
+
onExit?.();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (key.name === "escape") {
|
|
537
|
+
if (queue.length) {
|
|
538
|
+
queue = [];
|
|
539
|
+
} else {
|
|
540
|
+
clearInput();
|
|
541
|
+
}
|
|
542
|
+
refresh();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (key.name === "return" || key.name === "enter") {
|
|
547
|
+
submit();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (key.name === "up" || key.name === "down") {
|
|
552
|
+
if (history.length === 0) return;
|
|
553
|
+
if (key.name === "up") {
|
|
554
|
+
if (historyIndex === -1) {
|
|
555
|
+
draft = text();
|
|
556
|
+
historyIndex = history.length - 1;
|
|
557
|
+
} else if (historyIndex > 0) {
|
|
558
|
+
historyIndex -= 1;
|
|
559
|
+
}
|
|
560
|
+
setText(history[historyIndex]);
|
|
561
|
+
} else if (historyIndex !== -1) {
|
|
562
|
+
if (historyIndex < history.length - 1) {
|
|
563
|
+
historyIndex += 1;
|
|
564
|
+
setText(history[historyIndex]);
|
|
565
|
+
} else {
|
|
566
|
+
historyIndex = -1;
|
|
567
|
+
setText(draft);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
refresh();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
handleEditing(str, key);
|
|
575
|
+
refresh();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const onResize = () => {
|
|
579
|
+
screen.resize();
|
|
580
|
+
refresh();
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const api = {
|
|
584
|
+
start() {
|
|
585
|
+
if (started) return;
|
|
586
|
+
started = true;
|
|
587
|
+
readline.emitKeypressEvents(input);
|
|
588
|
+
if (input.isTTY) input.setRawMode(true);
|
|
589
|
+
if (screen.isTty) {
|
|
590
|
+
output.write?.(PASTE_ON);
|
|
591
|
+
disarmRestore = armTerminalRestore(output);
|
|
592
|
+
}
|
|
593
|
+
input.on("keypress", onKeypress);
|
|
594
|
+
output.on?.("resize", onResize);
|
|
595
|
+
input.resume?.();
|
|
596
|
+
refresh();
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
stop() {
|
|
600
|
+
if (!started) return;
|
|
601
|
+
started = false;
|
|
602
|
+
input.off("keypress", onKeypress);
|
|
603
|
+
output.off?.("resize", onResize);
|
|
604
|
+
// Left enabled, the next program to own this terminal receives paste
|
|
605
|
+
// markers it does not understand and prints them as literal text.
|
|
606
|
+
if (screen.isTty) output.write?.(PASTE_OFF);
|
|
607
|
+
disarmRestore?.();
|
|
608
|
+
disarmRestore = null;
|
|
609
|
+
if (input.isTTY) input.setRawMode(false);
|
|
610
|
+
input.pause?.();
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
refresh,
|
|
614
|
+
|
|
615
|
+
setBusy(value) {
|
|
616
|
+
busy = Boolean(value);
|
|
617
|
+
refresh();
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
get busy() {
|
|
621
|
+
return busy;
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
get history() {
|
|
625
|
+
return history.slice();
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
get interruptArmed() {
|
|
629
|
+
return interruptArmed;
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
/** Pull the next queued prompt, if the user typed ahead during a turn. */
|
|
633
|
+
shiftQueued() {
|
|
634
|
+
const next = queue.shift();
|
|
635
|
+
refresh();
|
|
636
|
+
return next;
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
get queued() {
|
|
640
|
+
return queue.slice();
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
clearQueue() {
|
|
644
|
+
queue = [];
|
|
645
|
+
refresh();
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Take over the composer for an approval. `choices` maps a single key to a
|
|
650
|
+
* return value; omit it for a free-text answer.
|
|
651
|
+
*/
|
|
652
|
+
question({ question, hint, choices, defaultValue = "no", freeText = false }) {
|
|
653
|
+
return new Promise((resolve) => {
|
|
654
|
+
// An approval can interrupt someone mid-sentence. Their draft is put
|
|
655
|
+
// back the moment the question is answered.
|
|
656
|
+
const heldDraft = text();
|
|
657
|
+
const heldCaret = caret;
|
|
658
|
+
clearInput();
|
|
659
|
+
mode = "ask";
|
|
660
|
+
ask = {
|
|
661
|
+
question,
|
|
662
|
+
hint: hint || (freeText ? "›" : "[y/n]"),
|
|
663
|
+
choices: choices || {},
|
|
664
|
+
defaultValue,
|
|
665
|
+
freeText,
|
|
666
|
+
resolve: (value) => {
|
|
667
|
+
chars = Array.from(heldDraft);
|
|
668
|
+
caret = Math.min(heldCaret, chars.length);
|
|
669
|
+
resolve(value);
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
refresh();
|
|
673
|
+
});
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
return api;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
module.exports = { createComposer };
|