@oxecli/oxe 1.0.15 → 1.0.17
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 +138 -18
- package/dist/config.js +5 -1
- package/dist/ui.js +216 -32
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { loadOrPrompt, default_reasoning_effort, max_action_chars, max_resume_history_items, } from "./config.js";
|
|
4
4
|
import { InferenceEngine, estimateTokens } from "./engine.js";
|
|
5
5
|
import { saveSession, loadSession, listSessions, nextSessionId, conversationLabel, COMMAND_HELP, toolOutputFailed, } from "./sessions.js";
|
|
6
|
-
import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, formatToolAction, truncateEllipsis, renderPanel, } from "./ui.js";
|
|
6
|
+
import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, formatToolAction, truncateEllipsis, renderPanel, renderTableString, enableRawStdin, disableRawStdin, waitRawKey, } from "./ui.js";
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
function truncateLabel(s) {
|
|
9
9
|
const t = String(s || "(conversation)").trim();
|
|
@@ -43,19 +43,49 @@ export class CLI {
|
|
|
43
43
|
const style = status === "failed" ? "\x1b[31m" : "\x1b[32m";
|
|
44
44
|
process.stdout.write(`${style}${truncateEllipsis(String(text), max_action_chars, "text")}\x1b[0m\n`);
|
|
45
45
|
}
|
|
46
|
-
showHelp() {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// ANSI codes don't skew the column alignment.
|
|
46
|
+
async showHelp() {
|
|
47
|
+
// Mirror Python's `_show_help`: a bordered table panel with the command
|
|
48
|
+
// column bold (with `<arg>` placeholders cyan) and descriptions dim, inside
|
|
49
|
+
// a panel titled "Commands · Enter / Ctrl+C closes". In a TTY it stays on
|
|
50
|
+
// screen until Enter/Ctrl+C/arrows/q closes it.
|
|
51
|
+
const rows = COMMAND_HELP.map(([cmd, desc]) => {
|
|
53
52
|
const styled = cmd.replace(/(<[^>]+>)/g, "\x1b[36m$1\x1b[0m");
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
return { cells: [`\x1b[1m${styled}\x1b[0m`, `\x1b[2m${desc}\x1b[0m`] };
|
|
54
|
+
});
|
|
55
|
+
const panel = renderTableString(rows, {
|
|
56
|
+
title: "Commands · Enter / Ctrl+C closes",
|
|
57
|
+
borderStyle: "90",
|
|
58
|
+
expand: false,
|
|
59
|
+
titleAlign: "left",
|
|
60
|
+
colGap: 2,
|
|
61
|
+
});
|
|
62
|
+
process.stdout.write("\n" + panel + "\n");
|
|
63
|
+
if (process.stdin.isTTY) {
|
|
64
|
+
// Wait for a close key, then erase the panel (mirrors Python's Live).
|
|
65
|
+
enableRawStdin();
|
|
66
|
+
try {
|
|
67
|
+
for (;;) {
|
|
68
|
+
if (await this.awaitRawCloseKey())
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
disableRawStdin();
|
|
74
|
+
}
|
|
75
|
+
const rowsShown = panel.split("\n").length;
|
|
76
|
+
process.stdout.write(`\x1b[${rowsShown}A\r\x1b[J`);
|
|
56
77
|
}
|
|
57
78
|
process.stdout.write("\n");
|
|
58
79
|
}
|
|
80
|
+
async awaitRawCloseKey() {
|
|
81
|
+
const k = await waitRawKey();
|
|
82
|
+
const closeNames = ["escape", "return", "enter", "up", "down", "left", "right"];
|
|
83
|
+
if (k.ctrl && (k.name === "c" || k.name === "z"))
|
|
84
|
+
return true;
|
|
85
|
+
if (k.str === "q" || k.str === "Q" || k.str === "\x1b" || closeNames.includes(k.name))
|
|
86
|
+
return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
59
89
|
renderHistory(items, maxItems = max_resume_history_items) {
|
|
60
90
|
const outputsByCallId = new Map();
|
|
61
91
|
for (const it of items) {
|
|
@@ -173,25 +203,110 @@ export class CLI {
|
|
|
173
203
|
}
|
|
174
204
|
process.stdout.write("\n");
|
|
175
205
|
}
|
|
176
|
-
|
|
206
|
+
/** Build the interactive picker panel (mirrors Python `_render_picker`). */
|
|
207
|
+
renderPickerPanel(recs, selected, visible = 8) {
|
|
208
|
+
const total = recs.length;
|
|
209
|
+
const half = Math.floor(visible / 2);
|
|
210
|
+
const lo = Math.min(Math.max(selected - half, 0), Math.max(total - visible, 0));
|
|
211
|
+
const hi = Math.min(lo + visible, total);
|
|
212
|
+
const rows = [];
|
|
213
|
+
for (let idx = lo; idx < hi; idx++) {
|
|
214
|
+
const rec = recs[idx];
|
|
215
|
+
const sid = String(rec["id"] ?? "?");
|
|
216
|
+
const updated = String(rec["updated_at"] ?? "").slice(0, 16).replace("T", " ");
|
|
217
|
+
const label = truncateLabel(rec["label"]);
|
|
218
|
+
const items = rec["input_items"] ?? [];
|
|
219
|
+
const toks = estimateTokens(items).toLocaleString();
|
|
220
|
+
const content = `\x1b[1m${sid.padStart(4)}\x1b[0m` +
|
|
221
|
+
`\x1b[2m ${updated}\x1b[0m ${label}` +
|
|
222
|
+
`\x1b[2m ${items.length} items · ${toks} tok\x1b[0m`;
|
|
223
|
+
rows.push({
|
|
224
|
+
cells: [idx === selected ? "\x1b[1m\x1b[36m❯\x1b[0m" : "", content],
|
|
225
|
+
style: idx === selected ? "\x1b[1m" : undefined,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const below = total - hi;
|
|
229
|
+
let footer = "";
|
|
230
|
+
if (lo > 0 && below > 0)
|
|
231
|
+
footer = `\x1b[2m▲ ${lo} earlier · ▼ ${below} more\x1b[0m`;
|
|
232
|
+
else if (lo > 0)
|
|
233
|
+
footer = `\x1b[2m▲ ${lo} earlier\x1b[0m`;
|
|
234
|
+
else if (below > 0)
|
|
235
|
+
footer = `\x1b[2m▼ ${below} more\x1b[0m`;
|
|
236
|
+
if (footer)
|
|
237
|
+
rows.push({ cells: ["", footer] });
|
|
238
|
+
const title = `Conversations \x1b[1m\x1b[36m${lo + 1}-${hi}/${total}\x1b[0m (↑/↓ · Quit (q) · Enter)`;
|
|
239
|
+
return renderTableString(rows, {
|
|
240
|
+
title,
|
|
241
|
+
borderStyle: "90",
|
|
242
|
+
expand: true,
|
|
243
|
+
titleAlign: "left",
|
|
244
|
+
colGap: 0,
|
|
245
|
+
colWidths: [3, undefined],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Interactive arrow up/down project picker (mirrors Python `_pick_conversation`). */
|
|
249
|
+
async pickConversation() {
|
|
177
250
|
const recs = listSessions();
|
|
178
251
|
if (!recs.length) {
|
|
179
252
|
process.stdout.write("\n");
|
|
180
253
|
renderPanel("No saved conversations yet. Type a prompt to start one.", "Conversations");
|
|
181
254
|
return null;
|
|
182
255
|
}
|
|
183
|
-
|
|
184
|
-
|
|
256
|
+
if (!process.stdin.isTTY) {
|
|
257
|
+
this.showConversationsPlain(recs);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
enableRawStdin();
|
|
261
|
+
let selected = 0;
|
|
262
|
+
let lastRows = 0;
|
|
263
|
+
const draw = () => {
|
|
264
|
+
const frame = "\n" + this.renderPickerPanel(recs, selected);
|
|
265
|
+
const n = frame.split("\n").length;
|
|
266
|
+
if (lastRows > 0)
|
|
267
|
+
process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
|
|
268
|
+
process.stdout.write(frame);
|
|
269
|
+
lastRows = n;
|
|
270
|
+
};
|
|
271
|
+
draw();
|
|
272
|
+
try {
|
|
273
|
+
for (;;) {
|
|
274
|
+
const k = await waitRawKey();
|
|
275
|
+
if (k.name === "up") {
|
|
276
|
+
selected = (selected - 1 + recs.length) % recs.length;
|
|
277
|
+
draw();
|
|
278
|
+
}
|
|
279
|
+
else if (k.name === "down") {
|
|
280
|
+
selected = (selected + 1) % recs.length;
|
|
281
|
+
draw();
|
|
282
|
+
}
|
|
283
|
+
else if (k.name === "return" || k.name === "enter") {
|
|
284
|
+
return Number(recs[selected]["id"] ?? null);
|
|
285
|
+
}
|
|
286
|
+
else if (k.str === "q" ||
|
|
287
|
+
k.str === "Q" ||
|
|
288
|
+
k.name === "escape" ||
|
|
289
|
+
(k.ctrl && (k.name === "c" || k.name === "z"))) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
process.stdout.write(`\x1b[${lastRows - 1}A\r\x1b[J`);
|
|
296
|
+
disableRawStdin();
|
|
297
|
+
}
|
|
185
298
|
}
|
|
186
299
|
async resumeConversation(engine, num) {
|
|
187
300
|
const sid = parseInt(num, 10);
|
|
188
301
|
if (Number.isNaN(sid)) {
|
|
189
|
-
|
|
302
|
+
process.stdout.write("\n");
|
|
303
|
+
renderPanel(`Invalid conversation number: ${num}`, "Error", "", true, "31");
|
|
190
304
|
return;
|
|
191
305
|
}
|
|
192
306
|
const rec = loadSession(sid);
|
|
193
307
|
if (!rec) {
|
|
194
|
-
|
|
308
|
+
process.stdout.write("\n");
|
|
309
|
+
renderPanel(`Conversation ${sid} not found.`, "Error", "", true, "31");
|
|
195
310
|
return;
|
|
196
311
|
}
|
|
197
312
|
this.saveSession(engine);
|
|
@@ -232,7 +347,7 @@ export class CLI {
|
|
|
232
347
|
break;
|
|
233
348
|
}
|
|
234
349
|
if (lower === "/help") {
|
|
235
|
-
this.showHelp();
|
|
350
|
+
await this.showHelp();
|
|
236
351
|
continue;
|
|
237
352
|
}
|
|
238
353
|
if (lower === "/clear") {
|
|
@@ -249,7 +364,10 @@ export class CLI {
|
|
|
249
364
|
if (lower.startsWith("/resume")) {
|
|
250
365
|
const parts = input.trim().split(/\s+/);
|
|
251
366
|
if (parts.length === 1) {
|
|
252
|
-
this.pickConversation();
|
|
367
|
+
const picked = await this.pickConversation();
|
|
368
|
+
if (picked != null && !Number.isNaN(picked)) {
|
|
369
|
+
await this.resumeConversation(engine, String(picked));
|
|
370
|
+
}
|
|
253
371
|
}
|
|
254
372
|
else {
|
|
255
373
|
await this.resumeConversation(engine, parts[1]);
|
|
@@ -264,7 +382,9 @@ export class CLI {
|
|
|
264
382
|
process.stdout.write(`\n\x1b[2mReasoning effort set to ${effort}.\x1b[0m\n`);
|
|
265
383
|
}
|
|
266
384
|
else {
|
|
267
|
-
|
|
385
|
+
process.stdout.write("\n");
|
|
386
|
+
renderPanel("Usage: /effort none|low|high", "Error", "", true, "31");
|
|
387
|
+
process.stdout.write("\n");
|
|
268
388
|
}
|
|
269
389
|
continue;
|
|
270
390
|
}
|
package/dist/config.js
CHANGED
|
@@ -145,7 +145,7 @@ export const SYSTEM_PROMPT_BODY = "You are a terminal-based coding agent named O
|
|
|
145
145
|
"consequence in your response.\n";
|
|
146
146
|
export const API_KEY_MAX = 64;
|
|
147
147
|
import { validateOxeApiKey } from "./api.js";
|
|
148
|
-
import { renderPanel, markupToAnsi } from "./ui.js";
|
|
148
|
+
import { renderPanel, markupToAnsi, hideCursor, showCursor } from "./ui.js";
|
|
149
149
|
export async function loadOrPrompt() {
|
|
150
150
|
let api_key = "";
|
|
151
151
|
let key_data = {};
|
|
@@ -164,9 +164,13 @@ export async function loadOrPrompt() {
|
|
|
164
164
|
}
|
|
165
165
|
process.stdout.write("\n");
|
|
166
166
|
const authSpinner = new Spinner();
|
|
167
|
+
// promptApiKey echoes input with the cursor visible; hide it while the
|
|
168
|
+
// validating-key label runs so no stray block cursor sits beside it.
|
|
169
|
+
hideCursor();
|
|
167
170
|
authSpinner.start("Authenticating key with Oxe Cloud…");
|
|
168
171
|
const validation = await validateOxeApiKey(api_key);
|
|
169
172
|
authSpinner.stop();
|
|
173
|
+
showCursor();
|
|
170
174
|
if (validation.valid) {
|
|
171
175
|
key_data = validation.key_data || {};
|
|
172
176
|
process.stdout.write(markupToAnsi(`[green]✓ Oxe API Key verified[/green] [dim](${String(key_data["name"] ?? "Desktop")})[/dim]`) + "\n");
|
package/dist/ui.js
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
import * as readline from "node:readline";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
|
+
// Raw stdin key reading (for interactive help / resume picker)
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
let rawKeyListeners = 0;
|
|
6
|
+
/** Enter raw mode and emit keypress events on stdin. */
|
|
7
|
+
export function enableRawStdin() {
|
|
8
|
+
if (rawKeyListeners === 0) {
|
|
9
|
+
readline.emitKeypressEvents(process.stdin);
|
|
10
|
+
if (process.stdin.isTTY)
|
|
11
|
+
process.stdin.setRawMode(true);
|
|
12
|
+
process.stdin.resume();
|
|
13
|
+
hideCursor();
|
|
14
|
+
}
|
|
15
|
+
rawKeyListeners++;
|
|
16
|
+
}
|
|
17
|
+
/** Exit raw mode. */
|
|
18
|
+
export function disableRawStdin() {
|
|
19
|
+
rawKeyListeners = Math.max(0, rawKeyListeners - 1);
|
|
20
|
+
if (rawKeyListeners === 0) {
|
|
21
|
+
try {
|
|
22
|
+
process.stdin.setRawMode(false);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* ignore */
|
|
26
|
+
}
|
|
27
|
+
process.stdin.pause();
|
|
28
|
+
showCursor();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Wait for a single raw keypress. */
|
|
32
|
+
export function waitRawKey() {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const onKeypress = (str, key) => {
|
|
35
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
36
|
+
resolve({ name: key?.name ?? "", str: str ?? "", ctrl: !!(key?.ctrl) });
|
|
37
|
+
};
|
|
38
|
+
process.stdin.on("keypress", onKeypress);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
3
42
|
// Terminal helpers
|
|
4
43
|
// ---------------------------------------------------------------------------
|
|
5
44
|
export function isWindows() {
|
|
@@ -142,7 +181,7 @@ export function mutedMarkdown(text) {
|
|
|
142
181
|
* - title/subtitle are embedded in the top/bottom borders, centered by
|
|
143
182
|
* default (rich default) or left-aligned.
|
|
144
183
|
*/
|
|
145
|
-
|
|
184
|
+
function panelString(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
|
|
146
185
|
titleAlign = "center") {
|
|
147
186
|
const styled = markupToAnsi(content);
|
|
148
187
|
const styledLines = styled.split("\n");
|
|
@@ -172,17 +211,59 @@ titleAlign = "center") {
|
|
|
172
211
|
const right = fill - left;
|
|
173
212
|
return `${"─".repeat(left)}${inner}${"─".repeat(right)}`;
|
|
174
213
|
};
|
|
214
|
+
const out = [];
|
|
175
215
|
const top = `╭${embed(title, titleAlign)}╮`;
|
|
176
|
-
|
|
216
|
+
out.push(`\x1b[${borderStyle}m${top}\x1b[0m`);
|
|
177
217
|
for (let i = 0; i < styledLines.length; i++) {
|
|
178
218
|
const line = styledLines[i];
|
|
179
219
|
const plain = plainLines[i] ?? "";
|
|
180
220
|
// Keep exactly one space padding each side; only pad the right to fill.
|
|
181
221
|
const pad = Math.max(innerW - plain.length - 2, 0);
|
|
182
|
-
|
|
222
|
+
out.push(`\x1b[${borderStyle}m│\x1b[0m ${line}${" ".repeat(pad)} \x1b[${borderStyle}m│\x1b[0m`);
|
|
183
223
|
}
|
|
184
224
|
const bottom = `╰${embed(subtitle, "center")}╯`;
|
|
185
|
-
|
|
225
|
+
out.push(`\x1b[${borderStyle}m${bottom}\x1b[0m`);
|
|
226
|
+
return out.join("\n");
|
|
227
|
+
}
|
|
228
|
+
export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", // 90 = bright black (grey50)
|
|
229
|
+
titleAlign = "center") {
|
|
230
|
+
process.stdout.write(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
|
|
231
|
+
}
|
|
232
|
+
function plainLen(s) {
|
|
233
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Render a rich-style bordered table panel (mirrors rich's Table + Panel).
|
|
237
|
+
*
|
|
238
|
+
* `rows` is a list of {cells, style?} where `style` (ANSI prefix) is applied to
|
|
239
|
+
* the whole row. Columns are left-aligned and padded; `colWidths` can fix a
|
|
240
|
+
* column's width (used for the selection gutter).
|
|
241
|
+
*/
|
|
242
|
+
export function renderTableString(rows, opts = {}) {
|
|
243
|
+
const ncols = Math.max(0, ...rows.map((r) => r.cells.length));
|
|
244
|
+
if (ncols === 0)
|
|
245
|
+
return panelString("", opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
246
|
+
const colGap = opts.colGap ?? 2;
|
|
247
|
+
const widths = [];
|
|
248
|
+
for (let c = 0; c < ncols; c++) {
|
|
249
|
+
let mw = 0;
|
|
250
|
+
for (const r of rows)
|
|
251
|
+
if (r.cells[c])
|
|
252
|
+
mw = Math.max(mw, plainLen(r.cells[c]));
|
|
253
|
+
widths.push(Math.max(mw, opts.colWidths?.[c] ?? 0));
|
|
254
|
+
}
|
|
255
|
+
const contentLines = [];
|
|
256
|
+
for (const r of rows) {
|
|
257
|
+
const parts = [];
|
|
258
|
+
for (let c = 0; c < ncols; c++) {
|
|
259
|
+
const cell = r.cells[c] ?? "";
|
|
260
|
+
const pad = c < ncols - 1 ? widths[c] - plainLen(cell) + colGap : 0;
|
|
261
|
+
parts.push(cell + " ".repeat(Math.max(pad, 0)));
|
|
262
|
+
}
|
|
263
|
+
const line = parts.join("");
|
|
264
|
+
contentLines.push(r.style ? `${r.style}${line}\x1b[0m` : line);
|
|
265
|
+
}
|
|
266
|
+
return panelString(contentLines.join("\n"), opts.title ?? "", opts.subtitle ?? "", opts.expand, opts.borderStyle, opts.titleAlign);
|
|
186
267
|
}
|
|
187
268
|
// ---------------------------------------------------------------------------
|
|
188
269
|
// Durations
|
|
@@ -456,15 +537,76 @@ export function splitBlocks(buffer, pasteSpans) {
|
|
|
456
537
|
}
|
|
457
538
|
return segs;
|
|
458
539
|
}
|
|
540
|
+
const PROMPT_CARET = "\u0000"; // sentinel marking the caret position
|
|
541
|
+
/**
|
|
542
|
+
* Wrap styled runs into physical rows of `width` visible columns, wrapping long
|
|
543
|
+
* lines so the panel box stays rectangular (mirrors rich's Panel auto-wrap).
|
|
544
|
+
* Returns the styled rows (no borders) and the caret's (row, col) in content
|
|
545
|
+
* coordinates, or (-1,-1) if no caret sentinel is present.
|
|
546
|
+
*/
|
|
547
|
+
function wrapRuns(runs, width) {
|
|
548
|
+
const rows = [];
|
|
549
|
+
let cur = "";
|
|
550
|
+
let curLen = 0;
|
|
551
|
+
let caretRow = -1;
|
|
552
|
+
let caretCol = -1;
|
|
553
|
+
const flush = () => {
|
|
554
|
+
rows.push(cur);
|
|
555
|
+
cur = "";
|
|
556
|
+
curLen = 0;
|
|
557
|
+
};
|
|
558
|
+
for (const run of runs) {
|
|
559
|
+
if (run.text === PROMPT_CARET) {
|
|
560
|
+
// Caret occupies one visible column; if the current row is full, move it
|
|
561
|
+
// to the start of the next row so it stays inside the box.
|
|
562
|
+
if (curLen >= width)
|
|
563
|
+
flush();
|
|
564
|
+
caretRow = rows.length;
|
|
565
|
+
caretCol = curLen;
|
|
566
|
+
cur += run.style + "▏" + "\x1b[0m";
|
|
567
|
+
curLen += 1;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
let text = run.text;
|
|
571
|
+
while (text) {
|
|
572
|
+
// Split off the leading run up to the next newline (if any).
|
|
573
|
+
const nl = text.indexOf("\n");
|
|
574
|
+
const seg = nl === -1 ? text : text.slice(0, nl);
|
|
575
|
+
let rest = seg;
|
|
576
|
+
while (rest.length > 0) {
|
|
577
|
+
if (curLen >= width)
|
|
578
|
+
flush();
|
|
579
|
+
const take = Math.min(rest.length, width - curLen);
|
|
580
|
+
const chunk = rest.slice(0, take);
|
|
581
|
+
cur += run.style ? run.style + chunk + "\x1b[0m" : chunk;
|
|
582
|
+
curLen += take;
|
|
583
|
+
rest = rest.slice(take);
|
|
584
|
+
}
|
|
585
|
+
if (nl !== -1) {
|
|
586
|
+
flush();
|
|
587
|
+
text = text.slice(nl + 1);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
flush();
|
|
595
|
+
return { rows, caretRow, caretCol };
|
|
596
|
+
}
|
|
459
597
|
export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor) {
|
|
460
598
|
const w = terminalWidth();
|
|
461
599
|
const borderW = Math.max(w - 4, 10);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
600
|
+
// Content width inside the box (between `│ ` and ` │`).
|
|
601
|
+
const innerW = borderW - 2;
|
|
602
|
+
// Build styled runs: prefix, then the text with the `▏` block caret always
|
|
603
|
+
// drawn at the cursor position (mirrors the original rich frame).
|
|
604
|
+
const runs = [];
|
|
605
|
+
runs.push({ text: prefix, style: "\x1b[1m" });
|
|
606
|
+
runs.push({ text: " ", style: "" });
|
|
466
607
|
if (!buffer) {
|
|
467
|
-
|
|
608
|
+
runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
|
|
609
|
+
runs.push({ text: PROMPT_PLACEHOLDER, style: "\x1b[2m" });
|
|
468
610
|
}
|
|
469
611
|
else {
|
|
470
612
|
const segs = splitBlocks(buffer, pasteSpans);
|
|
@@ -513,50 +655,49 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
|
|
|
513
655
|
inside = ranges.length ? ranges[ranges.length - 1][1] - ranges[ranges.length - 1][0] : 0;
|
|
514
656
|
}
|
|
515
657
|
}
|
|
516
|
-
display = "";
|
|
517
658
|
for (let i = 0; i < segs.length; i++) {
|
|
518
659
|
const [, kind, disp] = segs[i];
|
|
519
660
|
if (i && !endsWithWs(segs[i - 1][2]))
|
|
520
|
-
|
|
661
|
+
runs.push({ text: " ", style: "" });
|
|
521
662
|
if (i === target) {
|
|
522
663
|
if (kind === "collapsed") {
|
|
523
|
-
|
|
664
|
+
runs.push({ text: disp, style: "\x1b[1m\x1b[36m" });
|
|
524
665
|
if (!endsWithWs(disp))
|
|
525
|
-
|
|
526
|
-
|
|
666
|
+
runs.push({ text: " ", style: "" });
|
|
667
|
+
runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
|
|
527
668
|
}
|
|
528
669
|
else {
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
670
|
+
const before = disp.slice(0, inside);
|
|
671
|
+
const after = disp.slice(inside);
|
|
672
|
+
if (before)
|
|
673
|
+
runs.push({ text: before, style: "" });
|
|
674
|
+
runs.push({ text: PROMPT_CARET, style: "\x1b[1m" });
|
|
675
|
+
if (after)
|
|
676
|
+
runs.push({ text: after, style: "" });
|
|
533
677
|
}
|
|
534
678
|
}
|
|
535
679
|
else {
|
|
536
|
-
|
|
680
|
+
runs.push({ text: disp, style: kind === "collapsed" ? "\x1b[1m\x1b[36m" : "" });
|
|
537
681
|
}
|
|
538
682
|
}
|
|
539
683
|
}
|
|
540
|
-
const
|
|
541
|
-
const lines = content.split("\n");
|
|
684
|
+
const { rows, caretRow, caretCol } = wrapRuns(runs, innerW);
|
|
542
685
|
// Top border with the label embedded on the left.
|
|
543
686
|
const topPad = Math.max(borderW - label.length - 2, 0);
|
|
544
687
|
const top = `\x1b[90m╭─ ${label} ${"─".repeat(topPad)}╮\x1b[0m`;
|
|
545
688
|
// Body rows.
|
|
546
689
|
const body = [];
|
|
547
|
-
for (const l of
|
|
690
|
+
for (const l of rows) {
|
|
548
691
|
const plain = l.replace(/\x1b\[[0-9;]*m/g, "");
|
|
549
692
|
const pad = Math.max(borderW - 2 - plain.length, 0);
|
|
550
693
|
body.push(`\x1b[90m│\x1b[0m ${l}${" ".repeat(pad)} \x1b[90m│\x1b[0m`);
|
|
551
694
|
}
|
|
552
695
|
const bottom = `\x1b[90m╰${"─".repeat(borderW)}╯\x1b[0m`;
|
|
553
696
|
const frame = [top, ...body, bottom].join("\n");
|
|
554
|
-
// Cursor placement: one row below the top border.
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const cursorRow = row + 1;
|
|
559
|
-
const cursorCol = col + prefix.length + 4;
|
|
697
|
+
// Cursor placement: one row below the top border. The body prefix (`│ `)
|
|
698
|
+
// shifts the content right by 2 columns, so add 2 to the wrapped caret col.
|
|
699
|
+
const cursorRow = (caretRow === -1 ? 0 : caretRow) + 1;
|
|
700
|
+
const cursorCol = (caretCol === -1 ? 0 : caretCol) + 2;
|
|
560
701
|
const totalRows = body.length + 2;
|
|
561
702
|
return { frame, cursorRow, cursorCol, totalRows };
|
|
562
703
|
}
|
|
@@ -735,15 +876,32 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
735
876
|
pasteSpans = adjusted.filter(([s, e]) => s < e);
|
|
736
877
|
};
|
|
737
878
|
const moveLeft = () => {
|
|
738
|
-
if (cursor
|
|
739
|
-
|
|
879
|
+
if (cursor <= 0)
|
|
880
|
+
return;
|
|
881
|
+
// Jump the caret to the start of a paste span it sits just past (mirrors
|
|
882
|
+
// Python), so a collapsed block is treated as one unit.
|
|
883
|
+
for (const [s, e] of pasteSpans) {
|
|
884
|
+
if (s < cursor && cursor <= e) {
|
|
885
|
+
cursor = s;
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
cursor -= 1;
|
|
740
890
|
};
|
|
741
891
|
const moveRight = () => {
|
|
742
|
-
if (cursor
|
|
743
|
-
|
|
892
|
+
if (cursor >= buffer.length)
|
|
893
|
+
return;
|
|
894
|
+
for (const [s, e] of pasteSpans) {
|
|
895
|
+
if (s <= cursor && cursor < e) {
|
|
896
|
+
cursor = e;
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
cursor += 1;
|
|
744
901
|
};
|
|
745
902
|
const moveUp = () => {
|
|
746
903
|
if (!buffer.includes("\n")) {
|
|
904
|
+
// Single row: navigate command history.
|
|
747
905
|
if (!hist.length)
|
|
748
906
|
return;
|
|
749
907
|
if (histIdx === hist.length) {
|
|
@@ -755,10 +913,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
755
913
|
cursor = buffer.length;
|
|
756
914
|
pasteSpans = [];
|
|
757
915
|
}
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
// Multi row: move the caret up to the previous line, preserving column.
|
|
919
|
+
if (cursor > 0) {
|
|
920
|
+
let lineStart = buffer.lastIndexOf("\n", cursor - 1);
|
|
921
|
+
lineStart = lineStart !== -1 ? lineStart : 0;
|
|
922
|
+
let prevStart = buffer.lastIndexOf("\n", lineStart - 1);
|
|
923
|
+
prevStart = prevStart !== -1 ? prevStart : 0;
|
|
924
|
+
const [, col] = cursorLineCol(buffer, cursor);
|
|
925
|
+
cursor = prevStart + Math.min(col, lineStart - prevStart);
|
|
758
926
|
}
|
|
759
927
|
};
|
|
760
928
|
const moveDown = () => {
|
|
761
929
|
if (!buffer.includes("\n")) {
|
|
930
|
+
// Single row: navigate command history.
|
|
762
931
|
if (histIdx === hist.length || !draft)
|
|
763
932
|
return;
|
|
764
933
|
histIdx += 1;
|
|
@@ -772,6 +941,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
|
|
|
772
941
|
cursor = buffer.length;
|
|
773
942
|
pasteSpans = [];
|
|
774
943
|
}
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
// Multi row: move the caret down to the next line, preserving column.
|
|
947
|
+
if (cursor < buffer.length) {
|
|
948
|
+
const [, col] = cursorLineCol(buffer, cursor);
|
|
949
|
+
let nextStart = buffer.indexOf("\n", cursor);
|
|
950
|
+
if (nextStart === -1) {
|
|
951
|
+
cursor = buffer.length;
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
nextStart += 1;
|
|
955
|
+
let nxtEnd = buffer.indexOf("\n", nextStart);
|
|
956
|
+
nxtEnd = nxtEnd !== -1 ? nxtEnd : buffer.length;
|
|
957
|
+
cursor = Math.min(nextStart + col, nxtEnd);
|
|
958
|
+
}
|
|
775
959
|
}
|
|
776
960
|
};
|
|
777
961
|
const onKeypress = (str, key) => {
|