@oxecli/oxe 1.0.50 → 1.0.52
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/dist/cli.js +67 -63
- package/dist/config.js +35 -35
- package/dist/engine.js +42 -42
- package/dist/skills.js +10 -4
- package/dist/system.js +2 -2
- package/dist/tools.js +103 -65
- package/dist/ui.js +242 -282
- package/package.json +1 -1
package/dist/ui.js
CHANGED
|
@@ -28,10 +28,6 @@ export function disableRawStdin() {
|
|
|
28
28
|
catch {
|
|
29
29
|
/* ignore */
|
|
30
30
|
}
|
|
31
|
-
// Do NOT pause stdin here: askBottomPrompt re-acquires raw mode and
|
|
32
|
-
// resumes the stream itself. Pausing can race with that resume and drop
|
|
33
|
-
// keypress events on some terminals (prompt appearing "locked" after an
|
|
34
|
-
// interactive help/picker).
|
|
35
31
|
showCursor();
|
|
36
32
|
}
|
|
37
33
|
}
|
|
@@ -40,7 +36,7 @@ export function waitRawKey() {
|
|
|
40
36
|
return new Promise((resolve) => {
|
|
41
37
|
const onKeypress = (str, key) => {
|
|
42
38
|
process.stdin.removeListener("keypress", onKeypress);
|
|
43
|
-
resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!
|
|
39
|
+
resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!key?.ctrl });
|
|
44
40
|
};
|
|
45
41
|
process.stdin.on("keypress", onKeypress);
|
|
46
42
|
});
|
|
@@ -52,22 +48,25 @@ export function isWindows() {
|
|
|
52
48
|
return process.platform === "win32";
|
|
53
49
|
}
|
|
54
50
|
export function enableAnsi() {
|
|
55
|
-
//
|
|
56
|
-
// No native SetConsoleMode available without a native module; rely on the host.
|
|
51
|
+
// Modern Windows Terminal and host consoles support ANSI automatically.
|
|
57
52
|
}
|
|
58
53
|
export function clearScreen() {
|
|
59
|
-
|
|
60
|
-
// crude but reliable clear
|
|
61
|
-
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
65
|
-
}
|
|
54
|
+
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
66
55
|
}
|
|
67
56
|
export function terminalWidth() {
|
|
68
57
|
return process.stdout.columns || 80;
|
|
69
58
|
}
|
|
70
59
|
// ---------------------------------------------------------------------------
|
|
60
|
+
// ANSI and plain text length helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b[()][AB012]|\x1b\[\?[0-9]+[a-zA-Z]|\x1b\[>[0-9]+[a-zA-Z]/g;
|
|
63
|
+
export function stripAnsi(s) {
|
|
64
|
+
return s.replace(ANSI_RE, "");
|
|
65
|
+
}
|
|
66
|
+
export function plainLen(s) {
|
|
67
|
+
return stripAnsi(s).length;
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
71
70
|
// Markup tag -> ANSI
|
|
72
71
|
// ---------------------------------------------------------------------------
|
|
73
72
|
const TAG_MAP = {
|
|
@@ -82,9 +81,17 @@ const TAG_MAP = {
|
|
|
82
81
|
magenta: "\x1b[35m",
|
|
83
82
|
cyan: "\x1b[36m",
|
|
84
83
|
white: "\x1b[37m",
|
|
84
|
+
bright_black: "\x1b[90m",
|
|
85
|
+
bright_red: "\x1b[91m",
|
|
86
|
+
bright_green: "\x1b[92m",
|
|
87
|
+
bright_yellow: "\x1b[93m",
|
|
88
|
+
bright_blue: "\x1b[94m",
|
|
89
|
+
bright_magenta: "\x1b[95m",
|
|
90
|
+
bright_cyan: "\x1b[96m",
|
|
91
|
+
bright_white: "\x1b[97m",
|
|
85
92
|
};
|
|
86
93
|
const RESET = "\x1b[0m";
|
|
87
|
-
const MARKUP_RE = /\[(\/?)([a-z0-
|
|
94
|
+
const MARKUP_RE = /\[(\/?)([a-z0-9_ ]+)\]/gi;
|
|
88
95
|
export function escapeMarkup(text) {
|
|
89
96
|
return text.replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
|
90
97
|
}
|
|
@@ -115,90 +122,143 @@ export function markupToAnsi(text) {
|
|
|
115
122
|
});
|
|
116
123
|
}
|
|
117
124
|
// ---------------------------------------------------------------------------
|
|
118
|
-
//
|
|
125
|
+
// Syntax Highlighter for Code Blocks
|
|
119
126
|
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
current = next;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (current || !wrapped.length)
|
|
153
|
-
wrapped.push(current);
|
|
154
|
-
return wrapped;
|
|
127
|
+
const KEYWORDS = new Set([
|
|
128
|
+
"const", "let", "var", "function", "class", "import", "export", "from",
|
|
129
|
+
"return", "if", "else", "for", "while", "do", "switch", "case", "break",
|
|
130
|
+
"continue", "async", "await", "try", "catch", "finally", "throw", "new",
|
|
131
|
+
"typeof", "instanceof", "interface", "type", "enum", "extends", "implements",
|
|
132
|
+
"public", "private", "protected", "readonly", "static", "def", "self",
|
|
133
|
+
"lambda", "with", "as", "yield", "pass", "None", "True", "False", "null",
|
|
134
|
+
"undefined", "true", "false", "fn", "struct", "pub", "impl", "mut", "use",
|
|
135
|
+
]);
|
|
136
|
+
function highlightCodeLine(line, _lang = "") {
|
|
137
|
+
if (!line)
|
|
138
|
+
return "";
|
|
139
|
+
// Comments
|
|
140
|
+
if (/^\s*(\/\/|#|\/\*)/.test(line)) {
|
|
141
|
+
return `\x1b[2;37m${line}\x1b[0m`;
|
|
142
|
+
}
|
|
143
|
+
// Tokenize words, strings, numbers
|
|
144
|
+
return line.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d+\.?\d*\b)|(\b[a-zA-Z_$][\w$]*\b)|(\/\/[^\n]*|#[^\n]*)/g, (m, str, num, ident, comment) => {
|
|
145
|
+
if (str)
|
|
146
|
+
return `\x1b[32m${str}\x1b[0m`;
|
|
147
|
+
if (num)
|
|
148
|
+
return `\x1b[33m${num}\x1b[0m`;
|
|
149
|
+
if (comment)
|
|
150
|
+
return `\x1b[2;37m${comment}\x1b[0m`;
|
|
151
|
+
if (ident && KEYWORDS.has(ident))
|
|
152
|
+
return `\x1b[1;36m${ident}\x1b[0m`;
|
|
153
|
+
return m;
|
|
155
154
|
});
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Rich Markdown -> ANSI Terminal Renderer
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
export function markdownToAnsi(text) {
|
|
160
|
+
const width = Math.max(terminalWidth() - 2, 40);
|
|
161
|
+
const rawLines = text.split("\n");
|
|
156
162
|
const out = [];
|
|
157
163
|
let inCode = false;
|
|
158
|
-
let
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
let codeLang = "";
|
|
165
|
+
let codeBuffer = [];
|
|
166
|
+
const flushCodeBlock = () => {
|
|
167
|
+
if (!codeBuffer.length && !codeLang)
|
|
168
|
+
return;
|
|
169
|
+
const badge = codeLang ? ` \x1b[1;36m${codeLang}\x1b[0m ` : " ";
|
|
170
|
+
const barLen = Math.max(10, Math.min(width - plainLen(badge) - 4, 60));
|
|
171
|
+
out.push(`\x1b[90m┌─${badge}${"─".repeat(barLen)}┐\x1b[0m`);
|
|
172
|
+
for (const cline of codeBuffer) {
|
|
173
|
+
const highlighted = highlightCodeLine(cline, codeLang);
|
|
174
|
+
out.push(`\x1b[90m│\x1b[0m ${highlighted}`);
|
|
163
175
|
}
|
|
176
|
+
out.push(`\x1b[90m└${"─".repeat(barLen + plainLen(badge) + 2)}┘\x1b[0m`);
|
|
177
|
+
codeBuffer = [];
|
|
178
|
+
codeLang = "";
|
|
164
179
|
};
|
|
165
|
-
for (
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
180
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
181
|
+
const raw = rawLines[i];
|
|
182
|
+
const fenceMatch = raw.match(/^(`{3,})(.*)$/);
|
|
183
|
+
if (fenceMatch) {
|
|
169
184
|
if (!inCode) {
|
|
170
185
|
inCode = true;
|
|
186
|
+
codeLang = fenceMatch[2].trim().toLowerCase();
|
|
187
|
+
codeBuffer = [];
|
|
171
188
|
continue;
|
|
172
189
|
}
|
|
173
190
|
else {
|
|
174
191
|
inCode = false;
|
|
175
|
-
|
|
192
|
+
flushCodeBlock();
|
|
176
193
|
continue;
|
|
177
194
|
}
|
|
178
195
|
}
|
|
179
196
|
if (inCode) {
|
|
180
|
-
|
|
197
|
+
codeBuffer.push(raw);
|
|
181
198
|
continue;
|
|
182
199
|
}
|
|
183
|
-
let
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
200
|
+
let line = raw;
|
|
201
|
+
// Headers
|
|
202
|
+
const h1 = line.match(/^#\s+(.+)$/);
|
|
203
|
+
if (h1) {
|
|
204
|
+
out.push(`\x1b[1;36m# ${h1[1]}\x1b[0m`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const h2 = line.match(/^##\s+(.+)$/);
|
|
208
|
+
if (h2) {
|
|
209
|
+
out.push(`\x1b[1;34m## ${h2[1]}\x1b[0m`);
|
|
210
|
+
continue;
|
|
194
211
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
212
|
+
const h3 = line.match(/^###\s+(.+)$/);
|
|
213
|
+
if (h3) {
|
|
214
|
+
out.push(`\x1b[1;37m### ${h3[1]}\x1b[0m`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Blockquote
|
|
218
|
+
const bq = line.match(/^>\s*(.+)$/);
|
|
219
|
+
if (bq) {
|
|
220
|
+
out.push(`\x1b[90m│\x1b[0m \x1b[3m${bq[1]}\x1b[0m`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Unordered List item
|
|
224
|
+
line = line.replace(/^(\s*)[-*+]\s+(.+)$/, "$1\x1b[36m•\x1b[0m $2");
|
|
225
|
+
// Ordered List item
|
|
226
|
+
line = line.replace(/^(\s*)(\d+)\.\s+(.+)$/, "$1\x1b[36m$2.\x1b[0m $3");
|
|
227
|
+
// Inline formatting:
|
|
228
|
+
// Bold + Italic (***text*** or ___text___)
|
|
229
|
+
line = line.replace(/\*\*\*([^*]+)\*\*\*/g, "\x1b[1;3m$1\x1b[0m");
|
|
230
|
+
// Bold (**text** or __text__)
|
|
231
|
+
line = line.replace(/\*\*([^*]+)\*\*/g, "\x1b[1m$1\x1b[0m");
|
|
232
|
+
// Italic (*text* or _text_)
|
|
233
|
+
line = line.replace(/(^|[^\w*])\*([^*\n]+)\*(?=$|[^\w*])/g, "$1\x1b[3m$2\x1b[0m");
|
|
234
|
+
// Inline code (`code`)
|
|
235
|
+
line = line.replace(/`([^`]+)`/g, "\x1b[1;36m`$1`\x1b[0m");
|
|
236
|
+
// Links [text](url) -> text (url)
|
|
237
|
+
line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "\x1b[4;36m$1\x1b[0m \x1b[2m($2)\x1b[0m");
|
|
238
|
+
// Word wrap paragraph line if too long
|
|
239
|
+
if (plainLen(line) > width && !line.startsWith(" ")) {
|
|
240
|
+
const words = line.split(" ");
|
|
241
|
+
let cur = "";
|
|
242
|
+
for (const w of words) {
|
|
243
|
+
if (plainLen(cur) + plainLen(w) + 1 > width) {
|
|
244
|
+
if (cur)
|
|
245
|
+
out.push(cur);
|
|
246
|
+
cur = w;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
cur = cur ? `${cur} ${w}` : w;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (cur)
|
|
253
|
+
out.push(cur);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
out.push(line);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (inCode) {
|
|
260
|
+
flushCodeBlock();
|
|
200
261
|
}
|
|
201
|
-
flushCode();
|
|
202
262
|
return out.join("\n");
|
|
203
263
|
}
|
|
204
264
|
export function aiMarkdown(text) {
|
|
@@ -212,39 +272,27 @@ export function mutedMarkdown(text) {
|
|
|
212
272
|
return markdownToAnsi(dimmed);
|
|
213
273
|
}
|
|
214
274
|
// ---------------------------------------------------------------------------
|
|
215
|
-
// Panel
|
|
275
|
+
// Panel Rendering
|
|
216
276
|
// ---------------------------------------------------------------------------
|
|
217
|
-
|
|
218
|
-
* Render a rich-style rounded panel.
|
|
219
|
-
*
|
|
220
|
-
* Matches rich's Panel behavior:
|
|
221
|
-
* - `expand=false` shrinks the frame to fit the content (banner/auth).
|
|
222
|
-
* - title/subtitle are embedded in the top/bottom borders, centered by
|
|
223
|
-
* default (rich default) or left-aligned.
|
|
224
|
-
*/
|
|
225
|
-
function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
|
|
226
|
-
titleAlign = "center") {
|
|
277
|
+
function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
|
|
227
278
|
const styled = markupToAnsi(content);
|
|
228
279
|
const styledLines = styled.split("\n");
|
|
229
|
-
const plainLines =
|
|
230
|
-
|
|
231
|
-
let contentW = 0;
|
|
280
|
+
const plainLines = styledLines.map((l) => stripAnsi(l));
|
|
281
|
+
let maxLineW = 0;
|
|
232
282
|
for (const p of plainLines)
|
|
233
|
-
|
|
234
|
-
contentW = Math.max(contentW, 1);
|
|
235
|
-
let innerW = contentW + 2;
|
|
283
|
+
maxLineW = Math.max(maxLineW, p.length);
|
|
236
284
|
if (title)
|
|
237
|
-
|
|
285
|
+
maxLineW = Math.max(maxLineW, plainLen(title) + 4);
|
|
238
286
|
if (subtitle)
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
287
|
+
maxLineW = Math.max(maxLineW, plainLen(subtitle) + 4);
|
|
288
|
+
const termW = terminalWidth();
|
|
289
|
+
const boxW = expand ? Math.max(termW - 4, 20) : Math.min(maxLineW + 4, termW - 4);
|
|
290
|
+
const innerW = boxW - 4;
|
|
243
291
|
const embed = (text, align) => {
|
|
244
292
|
if (!text)
|
|
245
|
-
return "─".repeat(
|
|
293
|
+
return "─".repeat(boxW - 2);
|
|
246
294
|
const inner = ` ${text} `;
|
|
247
|
-
const fill = Math.max(
|
|
295
|
+
const fill = Math.max(boxW - 2 - plainLen(inner), 0);
|
|
248
296
|
if (align === "left") {
|
|
249
297
|
return `${inner}${"─".repeat(fill)}`;
|
|
250
298
|
}
|
|
@@ -258,39 +306,32 @@ titleAlign = "center") {
|
|
|
258
306
|
for (let i = 0; i < styledLines.length; i++) {
|
|
259
307
|
const line = styledLines[i];
|
|
260
308
|
const plain = plainLines[i] ?? "";
|
|
261
|
-
|
|
262
|
-
const pad = Math.max(innerW - plain.length - 2, 0);
|
|
309
|
+
const pad = Math.max(innerW - plain.length, 0);
|
|
263
310
|
out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
|
|
264
311
|
}
|
|
265
312
|
const bottom = `╰${embed(subtitle, "center")}╯`;
|
|
266
313
|
out.push(`\x1b[${borderStyle}m${bottom}\x1b[0m`);
|
|
267
314
|
return out.join("\n");
|
|
268
315
|
}
|
|
269
|
-
export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90",
|
|
270
|
-
titleAlign = "center") {
|
|
316
|
+
export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
|
|
271
317
|
process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
|
|
272
318
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Render a rich-style bordered table panel (mirrors rich's Table + Panel).
|
|
278
|
-
*
|
|
279
|
-
* `rows` is a list of {cells, style?} where `style` (ANSI prefix) is applied to
|
|
280
|
-
* the whole row. Columns are left-aligned and padded; `colWidths` can fix a
|
|
281
|
-
* column's width (used for the selection gutter).
|
|
282
|
-
*/
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Table Panel Rendering
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
283
322
|
export function renderTableString(rows, opts = {}) {
|
|
284
323
|
const ncols = Math.max(0, ...rows.map((r) => r.cells.length));
|
|
285
|
-
if (ncols === 0)
|
|
324
|
+
if (ncols === 0) {
|
|
286
325
|
return panelString("", opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
326
|
+
}
|
|
287
327
|
const colGap = opts.colGap ?? 2;
|
|
288
328
|
const widths = [];
|
|
289
329
|
for (let c = 0; c < ncols; c++) {
|
|
290
330
|
let mw = 0;
|
|
291
|
-
for (const r of rows)
|
|
331
|
+
for (const r of rows) {
|
|
292
332
|
if (r.cells[c])
|
|
293
333
|
mw = Math.max(mw, plainLen(r.cells[c]));
|
|
334
|
+
}
|
|
294
335
|
widths.push(Math.max(mw, opts.colWidths?.[c] ?? 0));
|
|
295
336
|
}
|
|
296
337
|
const contentLines = [];
|
|
@@ -307,7 +348,7 @@ export function renderTableString(rows, opts = {}) {
|
|
|
307
348
|
return panelString(contentLines.join("\n"), opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
308
349
|
}
|
|
309
350
|
// ---------------------------------------------------------------------------
|
|
310
|
-
// Durations
|
|
351
|
+
// Durations & Text Formatting
|
|
311
352
|
// ---------------------------------------------------------------------------
|
|
312
353
|
export function formatDuration(seconds) {
|
|
313
354
|
const total = Math.max(0, Math.round(seconds));
|
|
@@ -325,22 +366,14 @@ export function formatDuration(seconds) {
|
|
|
325
366
|
return parts.join(" ");
|
|
326
367
|
}
|
|
327
368
|
export function tickDuration(seconds) {
|
|
328
|
-
// Durations are status values, not code. Keep the surrounding text muted
|
|
329
|
-
// while making values such as `0s` readable in normal white intensity.
|
|
330
|
-
// Restore dim afterward so the following labels keep their muted styling.
|
|
331
369
|
return `\x1b[22;37m${formatDuration(seconds)}\x1b[2m`;
|
|
332
370
|
}
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
// Text helpers
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
const FENCE_MARKER_RE = /`{3,}/g;
|
|
337
371
|
export function printAiChunk(chunk) {
|
|
338
|
-
// Streaming commits are slices of the model's exact text. Do not trim or
|
|
339
|
-
// append newlines here, otherwise paragraph breaks from the AI disappear.
|
|
340
372
|
if (!chunk)
|
|
341
373
|
return;
|
|
342
374
|
process.stdout.write(markdownToAnsi(chunk));
|
|
343
375
|
}
|
|
376
|
+
const FENCE_MARKER_RE = /`{3,}/g;
|
|
344
377
|
export function safeCommitPoint(text) {
|
|
345
378
|
let idx = text.lastIndexOf("\n\n");
|
|
346
379
|
while (idx !== -1) {
|
|
@@ -353,14 +386,15 @@ export function safeCommitPoint(text) {
|
|
|
353
386
|
}
|
|
354
387
|
export function truncateEllipsis(text, maxChars, label = "text") {
|
|
355
388
|
if (text.length > maxChars) {
|
|
356
|
-
return text.slice(0, maxChars) +
|
|
389
|
+
return (text.slice(0, maxChars) +
|
|
390
|
+
` …(${label} truncated: ${text.length.toLocaleString()} chars total)`);
|
|
357
391
|
}
|
|
358
392
|
return text;
|
|
359
393
|
}
|
|
360
394
|
export function displayRows(text) {
|
|
361
395
|
if (!text)
|
|
362
396
|
return 1;
|
|
363
|
-
const plain = text
|
|
397
|
+
const plain = stripAnsi(text);
|
|
364
398
|
const width = Math.max(terminalWidth() || 80, 1);
|
|
365
399
|
let rows = 0;
|
|
366
400
|
const lines = plain.split("\n");
|
|
@@ -372,7 +406,7 @@ export function displayRows(text) {
|
|
|
372
406
|
return rows;
|
|
373
407
|
}
|
|
374
408
|
// ---------------------------------------------------------------------------
|
|
375
|
-
// Cursor
|
|
409
|
+
// Cursor & Spinner
|
|
376
410
|
// ---------------------------------------------------------------------------
|
|
377
411
|
let cursorHidden = false;
|
|
378
412
|
export function hideCursor() {
|
|
@@ -388,11 +422,6 @@ export function showCursor() {
|
|
|
388
422
|
}
|
|
389
423
|
}
|
|
390
424
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
391
|
-
/**
|
|
392
|
-
* A transient status line with an animated dot spinner, updated in place
|
|
393
|
-
* (no new lines appended on every tick). On stop() the line is erased.
|
|
394
|
-
* Mirrors rich's console.status(..., spinner="dots").
|
|
395
|
-
*/
|
|
396
425
|
export class Spinner {
|
|
397
426
|
timer = null;
|
|
398
427
|
frame = 0;
|
|
@@ -415,7 +444,7 @@ export class Spinner {
|
|
|
415
444
|
this.timer = setInterval(() => {
|
|
416
445
|
this.frame++;
|
|
417
446
|
this.draw();
|
|
418
|
-
},
|
|
447
|
+
}, 80);
|
|
419
448
|
this.draw();
|
|
420
449
|
}
|
|
421
450
|
update(text) {
|
|
@@ -424,24 +453,20 @@ export class Spinner {
|
|
|
424
453
|
this.draw();
|
|
425
454
|
}
|
|
426
455
|
draw() {
|
|
427
|
-
const
|
|
456
|
+
const icon = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
|
|
457
|
+
const rendered = `\x1b[36m${icon}\x1b[0m \x1b[2m${this.text}\x1b[0m`;
|
|
428
458
|
const newRows = Math.max(1, displayRows(rendered));
|
|
429
459
|
const clearRows = Math.max(this.rows, newRows, 1);
|
|
430
|
-
// Cursor is always at the home position (col 0) after each draw. Clear
|
|
431
|
-
// `clearRows` lines downward from home, rewrite the content, then return
|
|
432
|
-
// the cursor to home (col 0) so the next frame overwrites in place.
|
|
433
460
|
for (let i = 0; i < clearRows; i++) {
|
|
434
461
|
process.stdout.write("\r\x1b[2K");
|
|
435
462
|
if (i < clearRows - 1)
|
|
436
463
|
process.stdout.write("\n");
|
|
437
464
|
}
|
|
438
|
-
// Cursor is now `clearRows-1` lines below home; move back to home.
|
|
439
465
|
if (clearRows > 1)
|
|
440
466
|
process.stdout.write(`\x1b[${clearRows - 1}A\r`);
|
|
441
467
|
else
|
|
442
468
|
process.stdout.write("\r");
|
|
443
469
|
process.stdout.write(rendered);
|
|
444
|
-
// Return cursor to home (col 0) for the next draw.
|
|
445
470
|
if (newRows > 1)
|
|
446
471
|
process.stdout.write(`\x1b[${newRows - 1}A\r`);
|
|
447
472
|
else
|
|
@@ -454,7 +479,6 @@ export class Spinner {
|
|
|
454
479
|
this.timer = null;
|
|
455
480
|
}
|
|
456
481
|
if (this.enabled && this.rows > 0) {
|
|
457
|
-
// Cursor is at home (col 0); clear the rendered rows downward.
|
|
458
482
|
for (let i = 0; i < this.rows; i++) {
|
|
459
483
|
process.stdout.write("\r\x1b[2K");
|
|
460
484
|
if (i < this.rows - 1)
|
|
@@ -469,7 +493,7 @@ export class Spinner {
|
|
|
469
493
|
}
|
|
470
494
|
}
|
|
471
495
|
// ---------------------------------------------------------------------------
|
|
472
|
-
// Tool
|
|
496
|
+
// Tool Action Formatter
|
|
473
497
|
// ---------------------------------------------------------------------------
|
|
474
498
|
export function formatToolAction(name, argumentsJson, status = "ok") {
|
|
475
499
|
let args = {};
|
|
@@ -492,19 +516,19 @@ export function formatToolAction(name, argumentsJson, status = "ok") {
|
|
|
492
516
|
const start = args["start_line"];
|
|
493
517
|
const end = args["end_line"];
|
|
494
518
|
const span = start != null || end != null
|
|
495
|
-
?
|
|
519
|
+
? ` (lines ${start ?? 1} to ${end ?? "end"})`
|
|
496
520
|
: "";
|
|
497
521
|
return phrase("Read", "Reading", "Failed to read", `${p}${span}`);
|
|
498
522
|
}
|
|
499
523
|
case "write_file":
|
|
500
|
-
return phrase("Wrote", "Writing", "Failed to write", `${p} (${String(args["content"] ?? "").length} chars)`);
|
|
524
|
+
return phrase("Wrote", "Writing", "Failed to write", `${p} (${String(args["content"] ?? "").length.toLocaleString()} chars)`);
|
|
501
525
|
case "edit_file": {
|
|
502
526
|
const suffix = args["replace_all"] ? " (replace all)" : "";
|
|
503
527
|
return phrase("Edited", "Editing", "Failed to edit", `${p}${suffix}`);
|
|
504
528
|
}
|
|
505
529
|
case "bash": {
|
|
506
530
|
const cwd = args["cwd"];
|
|
507
|
-
const location = cwd ? ` in ${cwd}` : "";
|
|
531
|
+
const location = cwd ? ` [in ${cwd}]` : "";
|
|
508
532
|
const command = truncateEllipsis(String(args["command"] ?? ""), 400, "command");
|
|
509
533
|
if (status === "started")
|
|
510
534
|
return `Running command${location}: ${command}`;
|
|
@@ -515,27 +539,27 @@ export function formatToolAction(name, argumentsJson, status = "ok") {
|
|
|
515
539
|
case "glob": {
|
|
516
540
|
const detail = String(args["pattern"] ?? "");
|
|
517
541
|
if (status === "failed")
|
|
518
|
-
return `Search failed for ${detail}`;
|
|
519
|
-
return phrase("Searched for", "Searching for", "", detail);
|
|
542
|
+
return `Search failed for: ${detail}`;
|
|
543
|
+
return phrase("Searched files for", "Searching files for", "Search failed for", detail);
|
|
520
544
|
}
|
|
521
545
|
case "grep": {
|
|
522
546
|
const detail = String(args["pattern"] ?? "");
|
|
523
547
|
if (status === "failed")
|
|
524
|
-
return `Grep failed for ${detail}`;
|
|
525
|
-
return phrase("Grepped for", "Grepping for", "", detail);
|
|
548
|
+
return `Grep failed for: ${detail}`;
|
|
549
|
+
return phrase("Grepped for", "Grepping for", "Grep failed for", detail);
|
|
526
550
|
}
|
|
527
551
|
case "load_skill": {
|
|
528
552
|
const skill = String(args["skill_name"] ?? "");
|
|
529
553
|
return phrase("Loaded skill", "Loading skill", "Failed to load skill", skill);
|
|
530
554
|
}
|
|
531
555
|
default:
|
|
532
|
-
return phrase("
|
|
556
|
+
return phrase("Executed", "Executing", "Failed to execute", name);
|
|
533
557
|
}
|
|
534
558
|
}
|
|
535
559
|
// ---------------------------------------------------------------------------
|
|
536
|
-
// Prompt
|
|
560
|
+
// Prompt Editing & Interactive Input
|
|
537
561
|
// ---------------------------------------------------------------------------
|
|
538
|
-
export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help
|
|
562
|
+
export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help for commands";
|
|
539
563
|
const MAX_PROMPT_DISPLAY_LINES = 12;
|
|
540
564
|
const MAX_PROMPT_PASTE_CHARS = 400;
|
|
541
565
|
function shouldCollapsePaste(text) {
|
|
@@ -577,13 +601,7 @@ export function splitBlocks(buffer, pasteSpans) {
|
|
|
577
601
|
}
|
|
578
602
|
return segs;
|
|
579
603
|
}
|
|
580
|
-
const PROMPT_CARET = "\u0000";
|
|
581
|
-
/**
|
|
582
|
-
* Wrap styled runs into physical rows of `width` visible columns, wrapping long
|
|
583
|
-
* lines so the panel box stays rectangular (mirrors rich's Panel auto-wrap).
|
|
584
|
-
* Returns the styled rows (no borders) and the caret's (row, col) in content
|
|
585
|
-
* coordinates, or (-1,-1) if no caret sentinel is present.
|
|
586
|
-
*/
|
|
604
|
+
const PROMPT_CARET = "\u0000";
|
|
587
605
|
function wrapRuns(runs, width) {
|
|
588
606
|
const rows = [];
|
|
589
607
|
let cur = "";
|
|
@@ -597,8 +615,6 @@ function wrapRuns(runs, width) {
|
|
|
597
615
|
};
|
|
598
616
|
for (const run of runs) {
|
|
599
617
|
if (run.text === PROMPT_CARET) {
|
|
600
|
-
// Caret occupies one visible column; if the current row is full, move it
|
|
601
|
-
// to the start of the next row so it stays inside the box.
|
|
602
618
|
if (curLen >= width)
|
|
603
619
|
flush();
|
|
604
620
|
caretRow = rows.length;
|
|
@@ -609,7 +625,6 @@ function wrapRuns(runs, width) {
|
|
|
609
625
|
}
|
|
610
626
|
let text = run.text;
|
|
611
627
|
while (text) {
|
|
612
|
-
// Split off the leading run up to the next newline (if any).
|
|
613
628
|
const nl = text.indexOf("\n");
|
|
614
629
|
const seg = nl === -1 ? text : text.slice(0, nl);
|
|
615
630
|
let rest = seg;
|
|
@@ -617,9 +632,6 @@ function wrapRuns(runs, width) {
|
|
|
617
632
|
if (curLen >= width)
|
|
618
633
|
flush();
|
|
619
634
|
let take = Math.min(rest.length, width - curLen);
|
|
620
|
-
// Keep normal text readable by wrapping at a word boundary. A single
|
|
621
|
-
// oversized token still falls back to a hard wrap so the frame stays
|
|
622
|
-
// within its width.
|
|
623
635
|
if (take < rest.length && curLen > 0) {
|
|
624
636
|
const lastSpace = rest.slice(0, take).lastIndexOf(" ");
|
|
625
637
|
if (lastSpace > 0)
|
|
@@ -643,32 +655,27 @@ function wrapRuns(runs, width) {
|
|
|
643
655
|
return { rows, caretRow, caretCol };
|
|
644
656
|
}
|
|
645
657
|
export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor) {
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
// Content
|
|
649
|
-
const innerW =
|
|
650
|
-
// Build styled runs: prefix, then the text with the `▏` block caret always
|
|
651
|
-
// drawn at the cursor position (mirrors the original rich frame).
|
|
658
|
+
const termW = terminalWidth();
|
|
659
|
+
const boxW = Math.max(termW - 4, 16);
|
|
660
|
+
// Content inside border: `│ ` on left (2 chars) and ` │` on right (2 chars)
|
|
661
|
+
const innerW = boxW - 4;
|
|
652
662
|
const runs = [];
|
|
653
|
-
runs.push({ text: prefix, style: "\x1b[
|
|
663
|
+
runs.push({ text: prefix, style: "\x1b[1;36m" });
|
|
654
664
|
runs.push({ text: " ", style: "" });
|
|
655
665
|
if (!buffer) {
|
|
656
|
-
runs.push({ text: PROMPT_CARET, style: "\x1b[
|
|
657
|
-
runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[
|
|
666
|
+
runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
|
|
667
|
+
runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2;37m" });
|
|
658
668
|
}
|
|
659
669
|
else {
|
|
660
670
|
const segs = splitBlocks(buffer, pasteSpans);
|
|
661
|
-
// Recompute each segment's buffer range [a, b) so we can locate the cursor.
|
|
662
671
|
const ranges = [];
|
|
663
672
|
{
|
|
664
|
-
let pos = 0;
|
|
665
673
|
const pts = new Set([0, buffer.length]);
|
|
666
674
|
for (const [s, e] of pasteSpans) {
|
|
667
675
|
pts.add(s);
|
|
668
676
|
pts.add(e);
|
|
669
677
|
}
|
|
670
678
|
const sorted = [...pts].sort((a, b) => a - b);
|
|
671
|
-
const isPaste = (a, b) => pasteSpans.some(([s, e]) => s <= a && b <= e);
|
|
672
679
|
const used = new Set();
|
|
673
680
|
for (const seg of segs) {
|
|
674
681
|
for (let i = 0; i < sorted.length - 1; i++) {
|
|
@@ -682,12 +689,13 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
|
|
|
682
689
|
}
|
|
683
690
|
}
|
|
684
691
|
}
|
|
685
|
-
// Determine the target segment + inside offset (mirrors Python).
|
|
686
692
|
let target = -1;
|
|
687
693
|
let inside = 0;
|
|
688
694
|
if (cursor >= buffer.length) {
|
|
689
695
|
target = segs.length - 1;
|
|
690
|
-
inside = ranges.length
|
|
696
|
+
inside = ranges.length
|
|
697
|
+
? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0]
|
|
698
|
+
: 0;
|
|
691
699
|
}
|
|
692
700
|
else {
|
|
693
701
|
for (let i = 0; i < ranges.length; i++) {
|
|
@@ -700,7 +708,9 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
|
|
|
700
708
|
}
|
|
701
709
|
if (target === -1) {
|
|
702
710
|
target = segs.length - 1;
|
|
703
|
-
inside = ranges.length
|
|
711
|
+
inside = ranges.length
|
|
712
|
+
? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0]
|
|
713
|
+
: 0;
|
|
704
714
|
}
|
|
705
715
|
}
|
|
706
716
|
for (let i = 0; i < segs.length; i++) {
|
|
@@ -709,41 +719,44 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
|
|
|
709
719
|
runs.push({ text: " ", style: "" });
|
|
710
720
|
if (i === target) {
|
|
711
721
|
if (kind === "collapsed") {
|
|
712
|
-
runs.push({ text: disp, style: "\x1b[
|
|
722
|
+
runs.push({ text: disp, style: "\x1b[1;36m" });
|
|
713
723
|
if (!endsWithWs(disp))
|
|
714
724
|
runs.push({ text: " ", style: "" });
|
|
715
|
-
runs.push({ text: PROMPT_CARET, style: "\x1b[
|
|
725
|
+
runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
|
|
716
726
|
}
|
|
717
727
|
else {
|
|
718
728
|
const before = disp.slice(0, inside);
|
|
719
729
|
const after = disp.slice(inside);
|
|
720
730
|
if (before)
|
|
721
731
|
runs.push({ text: before, style: "" });
|
|
722
|
-
runs.push({ text: PROMPT_CARET, style: "\x1b[
|
|
732
|
+
runs.push({ text: PROMPT_CARET, style: "\x1b[1;36m" });
|
|
723
733
|
if (after)
|
|
724
734
|
runs.push({ text: after, style: "" });
|
|
725
735
|
}
|
|
726
736
|
}
|
|
727
737
|
else {
|
|
728
|
-
runs.push({
|
|
738
|
+
runs.push({
|
|
739
|
+
text: disp,
|
|
740
|
+
style: kind === "collapsed" ? "\x1b[1;36m" : "",
|
|
741
|
+
});
|
|
729
742
|
}
|
|
730
743
|
}
|
|
731
744
|
}
|
|
732
745
|
const { rows, caretRow, caretCol } = wrapRuns(runs, innerW);
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
|
|
746
|
+
// Math for exact box alignment:
|
|
747
|
+
// Top: `╭─ ` (3 chars) + `label` (L chars) + ` ` (1 char) + topPad + `╮` (1 char) = boxW
|
|
748
|
+
const labelLen = plainLen(label);
|
|
749
|
+
const topPad = Math.max(0, boxW - labelLen - 5);
|
|
750
|
+
const top = `\x1b[90m╭─ \x1b[1m\x1b[36m${label}\x1b[0m\x1b[90m ${"─".repeat(topPad)}╮\x1b[0m`;
|
|
737
751
|
const body = [];
|
|
738
752
|
for (const l of rows) {
|
|
739
|
-
const plain = l
|
|
740
|
-
const pad = Math.max(
|
|
753
|
+
const plain = stripAnsi(l);
|
|
754
|
+
const pad = Math.max(0, innerW - plain.length);
|
|
741
755
|
body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
|
|
742
756
|
}
|
|
743
|
-
|
|
757
|
+
// Bottom: `╰` (1 char) + `─` * (boxW - 2) + `╯` (1 char) = boxW
|
|
758
|
+
const bottom = `\x1b[90m╰${"─".repeat(Math.max(0, boxW - 2))}╯\x1b[0m`;
|
|
744
759
|
const frame = [top, ...body, bottom].join("\n");
|
|
745
|
-
// Cursor placement: one row below the top border. The body prefix (`│ `)
|
|
746
|
-
// shifts the content right by 2 columns, so add 2 to the wrapped caret col.
|
|
747
760
|
const cursorRow = (caretRow === -1 ? 0 : caretRow) + 1;
|
|
748
761
|
const cursorCol = (caretCol === -1 ? 0 : caretCol) + 2;
|
|
749
762
|
const totalRows = body.length + 2;
|
|
@@ -753,14 +766,14 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
753
766
|
return new Promise((resolve, reject) => {
|
|
754
767
|
const isTTY = process.stdin.isTTY;
|
|
755
768
|
if (!isTTY) {
|
|
756
|
-
// Non-interactive: read a line from stdin
|
|
757
769
|
const chunks = [];
|
|
758
770
|
process.stdin.setEncoding("utf-8");
|
|
759
771
|
process.stdin.on("data", (d) => chunks.push(Buffer.from(String(d))));
|
|
760
772
|
process.stdin.on("end", () => {
|
|
761
|
-
const text = Buffer.concat(chunks)
|
|
762
|
-
|
|
763
|
-
|
|
773
|
+
const text = Buffer.concat(chunks)
|
|
774
|
+
.toString()
|
|
775
|
+
.replace(/\r\n/g, "\n")
|
|
776
|
+
.replace(/\r/g, "\n");
|
|
764
777
|
resolve([text.endsWith("\n") ? text.slice(0, -1) : text, []]);
|
|
765
778
|
});
|
|
766
779
|
return;
|
|
@@ -778,12 +791,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
778
791
|
readline.emitKeypressEvents(process.stdin);
|
|
779
792
|
if (process.stdin.isTTY) {
|
|
780
793
|
process.stdin.setRawMode(true);
|
|
781
|
-
// Ask the terminal to wrap clipboard input in explicit paste markers.
|
|
782
|
-
// This makes embedded newlines unambiguous instead of relying on timing.
|
|
783
794
|
process.stdout.write("\x1b[?2004h");
|
|
784
|
-
// Enable the kitty keyboard protocol (CSI u) so Windows Terminal / modern
|
|
785
|
-
// terminals send distinct sequences for Shift+Enter / Ctrl+Enter
|
|
786
|
-
// (\x1b[13;<mod>u) instead of an indistinguishable plain \r.
|
|
787
795
|
process.stdout.write("\x1b[>1u");
|
|
788
796
|
}
|
|
789
797
|
process.stdin.resume();
|
|
@@ -792,11 +800,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
792
800
|
let lastCursorRow = 0;
|
|
793
801
|
let lastTotalRows = 0;
|
|
794
802
|
let prevFrameLines = null;
|
|
795
|
-
// Erase the prompt region: a leading blank line + the box. Cursor sits at
|
|
796
|
-
// lastCursorRow inside the box; move up to the box's top border, erase the
|
|
797
|
-
// box rows, then erase the leading blank line, leaving the cursor on that
|
|
798
|
-
// blank row. The caller's single leading newline then yields exactly ONE
|
|
799
|
-
// blank row before the echoed user prompt / message.
|
|
800
803
|
const clearBox = () => {
|
|
801
804
|
process.stdout.write(`\x1b[${lastCursorRow}A`);
|
|
802
805
|
for (let i = 0; i < lastTotalRows; i++) {
|
|
@@ -828,11 +831,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
828
831
|
const { frame, cursorRow, cursorCol, totalRows } = renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor);
|
|
829
832
|
const newLines = frame.split("\n");
|
|
830
833
|
if (isFirst) {
|
|
831
|
-
// The caller's content always ends with "\n", so the cursor sits at the
|
|
832
|
-
// start of a fresh (blank) line — that line is the leading blank
|
|
833
|
-
// separator (mirrors the original's Group(Text(""), panel)). Move down
|
|
834
|
-
// one line so the box starts below that blank; do NOT emit a second
|
|
835
|
-
// blank here (it caused a double gap above the box / on cancel).
|
|
836
834
|
process.stdout.write("\n");
|
|
837
835
|
for (let i = 0; i < newLines.length; i++) {
|
|
838
836
|
process.stdout.write(newLines[i]);
|
|
@@ -842,16 +840,10 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
842
840
|
prevFrameLines = newLines;
|
|
843
841
|
lastCursorRow = cursorRow;
|
|
844
842
|
lastTotalRows = totalRows;
|
|
845
|
-
// Cursor sits on the bottom border line; move it up to the input row.
|
|
846
843
|
const up = totalRows - 1 - cursorRow;
|
|
847
844
|
process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
|
|
848
|
-
return;
|
|
849
845
|
}
|
|
850
846
|
else {
|
|
851
|
-
// Cursor currently sits at the previous input position (lastCursorRow
|
|
852
|
-
// inside the box). Move up to the top border, then rewrite ONLY the
|
|
853
|
-
// lines whose content actually changed, leaving the static borders
|
|
854
|
-
// untouched — this avoids the whole-box flicker on every keystroke.
|
|
855
847
|
process.stdout.write(`\x1b[${lastCursorRow}A\r`);
|
|
856
848
|
const oldLines = prevFrameLines ?? [];
|
|
857
849
|
const maxLen = Math.max(oldLines.length, newLines.length);
|
|
@@ -862,8 +854,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
862
854
|
if (i < maxLen - 1)
|
|
863
855
|
process.stdout.write("\n");
|
|
864
856
|
}
|
|
865
|
-
// Cursor now sits at line (maxLen-1), column 0. Move it to the target
|
|
866
|
-
// input row (cursorRow), handling line-count changes in either direction.
|
|
867
857
|
const atLine = maxLen - 1;
|
|
868
858
|
if (atLine > cursorRow)
|
|
869
859
|
process.stdout.write(`\x1b[${atLine - cursorRow}A`);
|
|
@@ -873,7 +863,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
873
863
|
prevFrameLines = newLines;
|
|
874
864
|
lastCursorRow = cursorRow;
|
|
875
865
|
lastTotalRows = totalRows;
|
|
876
|
-
return;
|
|
877
866
|
}
|
|
878
867
|
};
|
|
879
868
|
const shiftSpansAfterInsert = (pos, delta) => {
|
|
@@ -897,12 +886,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
897
886
|
}
|
|
898
887
|
const pasteStart = cursor;
|
|
899
888
|
buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
|
|
900
|
-
// Keep existing paste spans valid across the insertion point.
|
|
901
889
|
pasteSpans = shiftSpansAfterInsert(cursor, text.length);
|
|
902
|
-
// A real paste records a span so the prompt box can collapse it (mirrors
|
|
903
|
-
// Python's handle_paste). splitBlocks only collapses segments inside a
|
|
904
|
-
// span that meet the threshold, so small pastes stay expanded but remain
|
|
905
|
-
// tagged for cursor/backspace handling.
|
|
906
890
|
if (isPaste) {
|
|
907
891
|
pasteSpans.push([pasteStart, pasteStart + text.length]);
|
|
908
892
|
pasteSpans.sort((a, b) => a[0] - b[0]);
|
|
@@ -916,9 +900,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
916
900
|
draft = { buffer, cursor, spans: pasteSpans.slice() };
|
|
917
901
|
histIdx = hist.length;
|
|
918
902
|
}
|
|
919
|
-
// A collapsed paste is one editable unit. Backspace removes the entire
|
|
920
|
-
// pasted block, matching the Python prompt rather than deleting only its
|
|
921
|
-
// final character.
|
|
922
903
|
const containing = pasteSpans.find(([s, e]) => s < cursor && cursor <= e);
|
|
923
904
|
if (containing) {
|
|
924
905
|
const [start, end] = containing;
|
|
@@ -930,14 +911,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
930
911
|
cursor = start;
|
|
931
912
|
return;
|
|
932
913
|
}
|
|
933
|
-
// remove one grapheme before cursor
|
|
934
914
|
const before = Array.from(buffer.slice(0, cursor));
|
|
935
915
|
before.pop();
|
|
936
916
|
buffer = before.join("") + buffer.slice(cursor);
|
|
937
917
|
const deletedAt = cursor - 1;
|
|
938
918
|
cursor -= 1;
|
|
939
|
-
// Adjust spans: shrink any span covering the deleted char, shift spans
|
|
940
|
-
// that start after it left by one.
|
|
941
919
|
const adjusted = [];
|
|
942
920
|
for (const [s, e] of pasteSpans) {
|
|
943
921
|
if (deletedAt >= s && deletedAt < e) {
|
|
@@ -957,8 +935,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
957
935
|
const moveLeft = () => {
|
|
958
936
|
if (cursor <= 0)
|
|
959
937
|
return;
|
|
960
|
-
// Jump the caret to the start of a paste span it sits just past (mirrors
|
|
961
|
-
// Python), so a collapsed block is treated as one unit.
|
|
962
938
|
for (const [s, e] of pasteSpans) {
|
|
963
939
|
if (s < cursor && cursor <= e) {
|
|
964
940
|
cursor = s;
|
|
@@ -980,7 +956,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
980
956
|
};
|
|
981
957
|
const moveUp = () => {
|
|
982
958
|
if (!buffer.includes("\n")) {
|
|
983
|
-
// Single row: navigate command history.
|
|
984
959
|
if (!hist.length)
|
|
985
960
|
return;
|
|
986
961
|
if (histIdx === hist.length) {
|
|
@@ -994,7 +969,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
994
969
|
}
|
|
995
970
|
return;
|
|
996
971
|
}
|
|
997
|
-
// Multi row: move the caret up to the previous line, preserving column.
|
|
998
972
|
if (cursor > 0) {
|
|
999
973
|
let lineStart = buffer.lastIndexOf("\n", cursor - 1);
|
|
1000
974
|
lineStart = lineStart !== -1 ? lineStart : 0;
|
|
@@ -1006,7 +980,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1006
980
|
};
|
|
1007
981
|
const moveDown = () => {
|
|
1008
982
|
if (!buffer.includes("\n")) {
|
|
1009
|
-
// Single row: navigate command history.
|
|
1010
983
|
if (histIdx === hist.length || !draft)
|
|
1011
984
|
return;
|
|
1012
985
|
histIdx += 1;
|
|
@@ -1022,7 +995,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1022
995
|
}
|
|
1023
996
|
return;
|
|
1024
997
|
}
|
|
1025
|
-
// Multi row: move the caret down to the next line, preserving column.
|
|
1026
998
|
if (cursor < buffer.length) {
|
|
1027
999
|
const [, col] = cursorLineCol(buffer, cursor);
|
|
1028
1000
|
let nextStart = buffer.indexOf("\n", cursor);
|
|
@@ -1040,23 +1012,17 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1040
1012
|
const isNewlineKey = (key) => {
|
|
1041
1013
|
if (!key)
|
|
1042
1014
|
return false;
|
|
1043
|
-
// Bare \n / Ctrl+J = newline.
|
|
1044
1015
|
if (key.ctrl && key.name === "j")
|
|
1045
1016
|
return true;
|
|
1046
1017
|
if (key.name === "enter")
|
|
1047
1018
|
return !!(key.shift || key.ctrl || key.meta);
|
|
1048
|
-
// Enter with a modifier (Shift/Ctrl/Alt/Meta) = newline.
|
|
1049
1019
|
if (key.name === "return")
|
|
1050
1020
|
return !!(key.shift || key.ctrl || key.meta);
|
|
1051
|
-
// Kitty-keyboard-protocol / CSI-u modified Enter: \x1b[13;<m>u where
|
|
1052
|
-
// <m> is a modifier bitmask (2=Shift, 3=Alt, 5=Ctrl, 6=Shift+Ctrl, ...).
|
|
1053
|
-
// Also the legacy \x1b[13;<m>~ form. ANY modifier > 1 on Enter = newline.
|
|
1054
1021
|
const seq = key.sequence || "";
|
|
1055
1022
|
if (/^\x1b\[13;([2-9]|\d{2,})u$/.test(seq))
|
|
1056
1023
|
return true;
|
|
1057
1024
|
if (/^\x1b\[13;([2-9]|\d{2,})~$/.test(seq))
|
|
1058
1025
|
return true;
|
|
1059
|
-
// Legacy Shift+Enter decoded by Node as F3 with shift.
|
|
1060
1026
|
if (key.name === "f3" && key.shift)
|
|
1061
1027
|
return true;
|
|
1062
1028
|
return false;
|
|
@@ -1064,23 +1030,20 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1064
1030
|
const isModifiedEnterInput = (str, key) => {
|
|
1065
1031
|
const value = str || "";
|
|
1066
1032
|
const sequence = key?.sequence || "";
|
|
1067
|
-
// Some Windows terminals do not preserve modifier flags when readline
|
|
1068
|
-
// decodes Ctrl+J or Ctrl/Shift+Enter. Inspect the original bytes too.
|
|
1069
1033
|
if (value === "\n" || value === "\x0a")
|
|
1070
1034
|
return true;
|
|
1071
1035
|
if (key?.name === "linefeed" || (key?.ctrl && key?.name === "j"))
|
|
1072
1036
|
return true;
|
|
1073
|
-
return /^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(value) ||
|
|
1074
|
-
/^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(sequence);
|
|
1037
|
+
return (/^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(value) ||
|
|
1038
|
+
/^\x1b\[13;(?:[2-9]|\d{2,})(?:u|~)$/.test(sequence));
|
|
1075
1039
|
};
|
|
1076
|
-
// True for a plain (unmodified) Enter: Node's "return" without modifiers,
|
|
1077
|
-
// or the kitty-protocol encodings \x1b[13u / \x1b[13;1u (modifier flags = 1
|
|
1078
|
-
// meaning none).
|
|
1079
1040
|
const isSubmitEnterKey = (key) => {
|
|
1080
1041
|
if (!key)
|
|
1081
1042
|
return false;
|
|
1082
|
-
if ((key.name === "return" || key.name === "enter") &&
|
|
1043
|
+
if ((key.name === "return" || key.name === "enter") &&
|
|
1044
|
+
!(key.shift || key.ctrl || key.meta)) {
|
|
1083
1045
|
return true;
|
|
1046
|
+
}
|
|
1084
1047
|
const seq = key.sequence || "";
|
|
1085
1048
|
if (seq === "\x1b[13u" || /^\x1b\[13;1u$/.test(seq))
|
|
1086
1049
|
return true;
|
|
@@ -1094,12 +1057,19 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1094
1057
|
if (!bracketedPaste && (sequence === start || value === start)) {
|
|
1095
1058
|
bracketedPaste = true;
|
|
1096
1059
|
bracketedPasteBuffer = "";
|
|
1097
|
-
const inline = value === start
|
|
1060
|
+
const inline = value === start
|
|
1061
|
+
? ""
|
|
1062
|
+
: value.startsWith(start)
|
|
1063
|
+
? value.slice(start.length)
|
|
1064
|
+
: "";
|
|
1098
1065
|
if (inline)
|
|
1099
1066
|
bracketedPasteBuffer = inline;
|
|
1100
1067
|
if (inline.includes(end)) {
|
|
1101
1068
|
const endAt = inline.indexOf(end);
|
|
1102
|
-
const pasted = inline
|
|
1069
|
+
const pasted = inline
|
|
1070
|
+
.slice(0, endAt)
|
|
1071
|
+
.replace(/\r\n/g, "\n")
|
|
1072
|
+
.replace(/\r/g, "\n");
|
|
1103
1073
|
bracketedPaste = false;
|
|
1104
1074
|
bracketedPasteBuffer = "";
|
|
1105
1075
|
if (pasted)
|
|
@@ -1111,7 +1081,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1111
1081
|
if (!bracketedPaste)
|
|
1112
1082
|
return false;
|
|
1113
1083
|
if (sequence === end || value === end) {
|
|
1114
|
-
const pasted = bracketedPasteBuffer
|
|
1084
|
+
const pasted = bracketedPasteBuffer
|
|
1085
|
+
.replace(/\r\n/g, "\n")
|
|
1086
|
+
.replace(/\r/g, "\n");
|
|
1115
1087
|
bracketedPaste = false;
|
|
1116
1088
|
bracketedPasteBuffer = "";
|
|
1117
1089
|
if (pasted)
|
|
@@ -1119,12 +1091,15 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1119
1091
|
repaint();
|
|
1120
1092
|
return true;
|
|
1121
1093
|
}
|
|
1122
|
-
const chunk = value ||
|
|
1094
|
+
const chunk = value ||
|
|
1095
|
+
(sequence && !sequence.startsWith("\x1b[") ? sequence : "");
|
|
1123
1096
|
if (chunk) {
|
|
1124
1097
|
const endAt = chunk.indexOf(end);
|
|
1125
1098
|
if (endAt >= 0) {
|
|
1126
1099
|
bracketedPasteBuffer += chunk.slice(0, endAt);
|
|
1127
|
-
const pasted = bracketedPasteBuffer
|
|
1100
|
+
const pasted = bracketedPasteBuffer
|
|
1101
|
+
.replace(/\r\n/g, "\n")
|
|
1102
|
+
.replace(/\r/g, "\n");
|
|
1128
1103
|
bracketedPaste = false;
|
|
1129
1104
|
bracketedPasteBuffer = "";
|
|
1130
1105
|
if (pasted)
|
|
@@ -1149,9 +1124,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1149
1124
|
const onKeypress = (str, key) => {
|
|
1150
1125
|
if (consumeBracketedPaste(str, key))
|
|
1151
1126
|
return;
|
|
1152
|
-
// A normal typed character after an unbracketed paste ends the fallback
|
|
1153
|
-
// window, so the next Enter can submit immediately. Multi-character
|
|
1154
|
-
// clipboard chunks keep the window alive until their rows arrive.
|
|
1155
1127
|
if (pasteBurst && str && str.length === 1 && !key?.ctrl && !key?.meta) {
|
|
1156
1128
|
pasteBurst = false;
|
|
1157
1129
|
if (pasteBurstTimer)
|
|
@@ -1166,18 +1138,13 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1166
1138
|
settle(new Error("eof"), true);
|
|
1167
1139
|
return;
|
|
1168
1140
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
// sequence.
|
|
1173
|
-
if (isNewlineKey(key) || isModifiedEnterInput(str, key) || (pasteBurst && isSubmitEnterKey(key))) {
|
|
1141
|
+
if (isNewlineKey(key) ||
|
|
1142
|
+
isModifiedEnterInput(str, key) ||
|
|
1143
|
+
(pasteBurst && isSubmitEnterKey(key))) {
|
|
1174
1144
|
insert("\n");
|
|
1175
1145
|
repaint();
|
|
1176
1146
|
return;
|
|
1177
1147
|
}
|
|
1178
|
-
// Plain Enter submits; any modified Enter (Shift/Ctrl+Enter) inserts a
|
|
1179
|
-
// newline, mirroring the Python original's _read_input_event (which maps
|
|
1180
|
-
// \x1b[13;2u / \x1b[13;5u / Shift+\r to "\n").
|
|
1181
1148
|
if (isSubmitEnterKey(key)) {
|
|
1182
1149
|
if (buffer.trim()) {
|
|
1183
1150
|
settle([buffer, pasteSpans], false);
|
|
@@ -1210,7 +1177,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1210
1177
|
return;
|
|
1211
1178
|
}
|
|
1212
1179
|
if (key && key.name === "tab") {
|
|
1213
|
-
insert("
|
|
1180
|
+
insert(" ");
|
|
1214
1181
|
repaint();
|
|
1215
1182
|
return;
|
|
1216
1183
|
}
|
|
@@ -1220,8 +1187,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1220
1187
|
return;
|
|
1221
1188
|
}
|
|
1222
1189
|
if (str) {
|
|
1223
|
-
// Multi-char sequence = a paste (single-char typed keys arrive one per
|
|
1224
|
-
// event). Tag it so the prompt box collapses it when it meets the rules.
|
|
1225
1190
|
const isPaste = str.length > 1;
|
|
1226
1191
|
insert(str.replace(/\r\n/g, "\n").replace(/\r/g, "\n"), isPaste);
|
|
1227
1192
|
if (isPaste)
|
|
@@ -1234,14 +1199,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
1234
1199
|
});
|
|
1235
1200
|
}
|
|
1236
1201
|
// ---------------------------------------------------------------------------
|
|
1237
|
-
// User
|
|
1202
|
+
// User Display & Message Text Helpers
|
|
1238
1203
|
// ---------------------------------------------------------------------------
|
|
1239
1204
|
export function userDisplayText(payload, pasteSpans) {
|
|
1240
|
-
// Mirror the prompt field exactly: collapse ONLY segments that fall inside a
|
|
1241
|
-
// paste span and exceed the collapse threshold (via splitBlocks). Content that
|
|
1242
|
-
// the user typed (no paste span) is never collapsed in the prompt, so it must
|
|
1243
|
-
// not be collapsed here either — keeping the echoed message consistent with
|
|
1244
|
-
// what the prompt box displayed.
|
|
1245
1205
|
if (pasteSpans && pasteSpans.length) {
|
|
1246
1206
|
const segs = splitBlocks(payload, pasteSpans);
|
|
1247
1207
|
let out = "";
|
|
@@ -1249,7 +1209,7 @@ export function userDisplayText(payload, pasteSpans) {
|
|
|
1249
1209
|
const [, kind, disp] = segs[i];
|
|
1250
1210
|
if (i && !endsWithWs(segs[i - 1][2]))
|
|
1251
1211
|
out += " ";
|
|
1252
|
-
out += kind === "collapsed" ? `\x1b[
|
|
1212
|
+
out += kind === "collapsed" ? `\x1b[1;36m${disp}\x1b[0m` : disp;
|
|
1253
1213
|
}
|
|
1254
1214
|
return out;
|
|
1255
1215
|
}
|