@lazyingart/agintiflow 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,7 +67,7 @@ aginti
67
67
  aginti chat
68
68
  ```
69
69
 
70
- Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
70
+ Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. `Ctrl+J` inserts a new line in the colored input panel, Enter sends, arrow keys move through wrapped multiline input, and `Ctrl+A`/`Ctrl+E` jump to the current line start/end. Assistant responses render common Markdown, including headings, inline code, bold text, lists, quotes, code fences, and tables. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
71
71
 
72
72
  For code edits, AgInTiFlow routes patch/refactor/database-style tasks to DeepSeek v4 pro by default and exposes `apply_patch` as a deterministic workspace tool. It supports exact replacements, Codex-style patch envelopes, and unified diffs, with preflight checks, path guardrails, hashes, and compact per-file diffs. See [docs/patch-tools.md](docs/patch-tools.md).
73
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { buildPromptLayout, stripMarkdown } from "../src/interactive-cli.js";
7
8
 
8
9
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-cli-chat-"));
@@ -52,6 +53,38 @@ function runCli(args, inputText) {
52
53
  }
53
54
 
54
55
  try {
56
+ const renderedMarkdown = stripMarkdown(
57
+ [
58
+ "**Docker status**",
59
+ "",
60
+ "| Check | Result |",
61
+ "| --- | --- |",
62
+ "| `/.dockerenv` | **Present** |",
63
+ "| Hostname | `abc123` |",
64
+ ].join("\n")
65
+ );
66
+ if (renderedMarkdown.includes("**") || renderedMarkdown.includes("| --- |")) {
67
+ throw new Error("terminal markdown renderer left raw markdown syntax");
68
+ }
69
+ if (!renderedMarkdown.includes("Check") || !renderedMarkdown.includes("Present")) {
70
+ throw new Error("terminal markdown renderer dropped table content");
71
+ }
72
+
73
+ const promptLayout = buildPromptLayout(`${"x".repeat(180)}\nsecond line`, 95, 80, 24);
74
+ const visibleLengths = promptLayout.renderedRows.map((line) =>
75
+ line.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").length
76
+ );
77
+ if (promptLayout.rows.length < 4 || Math.max(...visibleLengths) > 79) {
78
+ throw new Error("terminal prompt layout did not wrap long multiline input safely");
79
+ }
80
+ if (promptLayout.cursorRow < 0 || promptLayout.cursorColumn < 0) {
81
+ throw new Error("terminal prompt layout returned an invalid cursor location");
82
+ }
83
+ const hugePromptLayout = buildPromptLayout(Array.from({ length: 30 }, (_unused, index) => `line ${index + 1}`).join("\n"), 120, 80, 20);
84
+ if (hugePromptLayout.renderedRows.length > 12 || !hugePromptLayout.renderedRows.some((line) => line.includes("earlier input row"))) {
85
+ throw new Error("terminal prompt layout did not bound redraw size for large prompts");
86
+ }
87
+
55
88
  const result = await runChat("Create notes/interactive.md with a short CLI chat smoke message\n/exit\n");
56
89
  const written = await fs.readFile(path.join(tempRoot, "notes/interactive.md"), "utf8");
57
90
  if (!written.includes("Created by AgInTiFlow mock mode.")) {
@@ -74,7 +107,7 @@ try {
74
107
  {
75
108
  ok: true,
76
109
  projectRoot: tempRoot,
77
- checks: ["interactive-chat", "mock-file-write", "run-status", "resume-latest"],
110
+ checks: ["markdown-render", "prompt-layout", "interactive-chat", "mock-file-write", "run-status", "resume-latest"],
78
111
  },
79
112
  null,
80
113
  2
@@ -18,6 +18,9 @@ const ansi = {
18
18
  green: "\x1b[32m",
19
19
  yellow: "\x1b[33m",
20
20
  red: "\x1b[31m",
21
+ blue: "\x1b[34m",
22
+ magenta: "\x1b[35m",
23
+ faint: "\x1b[2m",
21
24
  clearLine: "\x1b[2K",
22
25
  cursorHide: "\x1b[?25l",
23
26
  cursorShow: "\x1b[?25h",
@@ -50,6 +53,7 @@ const SLASH_COMMANDS = [
50
53
  "/web",
51
54
  "/exit",
52
55
  ];
56
+ const promptHistory = [];
53
57
 
54
58
  function color(value, ...codes) {
55
59
  if (!useColor || codes.length === 0) return String(value);
@@ -64,6 +68,39 @@ function label(name, bgCode) {
64
68
  return color(` ${name} `, bgCode, ansi.bold);
65
69
  }
66
70
 
71
+ function terminalWidth() {
72
+ return Math.max(Number(output.columns) || 80, 40);
73
+ }
74
+
75
+ function terminalHeight() {
76
+ return Math.max(Number(output.rows) || 24, 10);
77
+ }
78
+
79
+ function editorWidth(width = terminalWidth()) {
80
+ return Math.max(Number(width) - 1, 39);
81
+ }
82
+
83
+ function promptViewportRows(height = terminalHeight()) {
84
+ return Math.max(Math.min(Math.floor(Number(height) * 0.42), 10), 4);
85
+ }
86
+
87
+ function visualLength(value) {
88
+ return stripAnsi(value).length;
89
+ }
90
+
91
+ function padVisible(value, width) {
92
+ const padding = Math.max(width - visualLength(value), 0);
93
+ return `${value}${" ".repeat(padding)}`;
94
+ }
95
+
96
+ function panelLine(content = "", bgCode = ansi.systemBg, width = editorWidth()) {
97
+ const raw = String(content || "");
98
+ const safeContent = visualLength(raw) > width ? stripAnsi(raw).slice(0, width) : raw;
99
+ if (!useColor) return padVisible(safeContent, width);
100
+ const padded = padVisible(safeContent, width).replaceAll(ansi.reset, `${ansi.reset}${bgCode}`);
101
+ return `${bgCode}${padded}${ansi.reset}`;
102
+ }
103
+
67
104
  function userPrompt() {
68
105
  return `\n${label("user>", ansi.userBg)} ${color("|", ansi.userBg)} `;
69
106
  }
@@ -90,12 +127,17 @@ function commandSuggestions(line = "") {
90
127
  return SLASH_COMMANDS.filter((command) => command.startsWith(trimmed)).slice(0, 8);
91
128
  }
92
129
 
93
- function stripMarkdown(text) {
130
+ function clamp(value, min, max) {
131
+ return Math.min(Math.max(value, min), max);
132
+ }
133
+
134
+ export function stripMarkdown(text) {
94
135
  const lines = String(text || "").split(/\r?\n/);
95
136
  let inFence = false;
96
137
  const rendered = [];
97
138
 
98
- for (const rawLine of lines) {
139
+ for (let index = 0; index < lines.length; index += 1) {
140
+ const rawLine = lines[index];
99
141
  let line = rawLine;
100
142
  if (/^\s*```/.test(line)) {
101
143
  inFence = !inFence;
@@ -119,6 +161,12 @@ function stripMarkdown(text) {
119
161
  if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)) {
120
162
  continue;
121
163
  }
164
+ const table = parseMarkdownTable(lines, index);
165
+ if (table) {
166
+ rendered.push(...renderMarkdownTable(table));
167
+ index += table.rawLineCount - 1;
168
+ continue;
169
+ }
122
170
  line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
123
171
  line = line.replace(/\*\*([^*]+)\*\*/g, (_, value) => color(value, ansi.bold));
124
172
  line = line.replace(/__([^_]+)__/g, (_, value) => color(value, ansi.bold));
@@ -137,6 +185,75 @@ function stripMarkdown(text) {
137
185
  return rendered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
138
186
  }
139
187
 
188
+ function splitMarkdownTableRow(line = "") {
189
+ const trimmed = String(line || "").trim();
190
+ if (!trimmed.includes("|")) return null;
191
+ const normalized = trimmed.replace(/^\|/, "").replace(/\|$/, "");
192
+ const cells = normalized.split("|").map((cell) => cell.trim());
193
+ return cells.length >= 2 ? cells : null;
194
+ }
195
+
196
+ function isMarkdownTableSeparator(line = "") {
197
+ const cells = splitMarkdownTableRow(line);
198
+ return Boolean(cells?.length) && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
199
+ }
200
+
201
+ function parseMarkdownTable(lines, startIndex) {
202
+ const header = splitMarkdownTableRow(lines[startIndex]);
203
+ if (!header || !isMarkdownTableSeparator(lines[startIndex + 1] || "")) return null;
204
+ const rows = [];
205
+ let index = startIndex + 2;
206
+ while (index < lines.length) {
207
+ const row = splitMarkdownTableRow(lines[index]);
208
+ if (!row) break;
209
+ rows.push(row);
210
+ index += 1;
211
+ }
212
+ return {
213
+ header,
214
+ rows,
215
+ rawLineCount: Math.max(index - startIndex, 2),
216
+ };
217
+ }
218
+
219
+ function renderMarkdownTable(table) {
220
+ const allRows = [table.header, ...table.rows];
221
+ const columnCount = Math.max(...allRows.map((row) => row.length));
222
+ const widths = Array.from({ length: columnCount }, (_unused, column) =>
223
+ Math.min(
224
+ Math.max(
225
+ ...allRows.map((row) => visualLength(stripMarkdownInline(row[column] || ""))),
226
+ 3
227
+ ),
228
+ 36
229
+ )
230
+ );
231
+ const formatRow = (row, header = false) =>
232
+ widths
233
+ .map((width, column) => {
234
+ const value = stripMarkdownInline(row[column] || "");
235
+ return padVisible(value, width);
236
+ })
237
+ .join(color(" │ ", ansi.dim));
238
+ const separator = widths.map((width) => "─".repeat(width)).join(color("──┼──", ansi.dim));
239
+ return [
240
+ color(formatRow(table.header, true), ansi.bold, ansi.cyan),
241
+ color(separator, ansi.dim),
242
+ ...table.rows.map((row) => formatRow(row)),
243
+ ];
244
+ }
245
+
246
+ function stripMarkdownInline(value = "") {
247
+ return String(value)
248
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
249
+ .replace(/\*\*([^*]+)\*\*/g, (_, text) => color(text, ansi.bold))
250
+ .replace(/__([^_]+)__/g, (_, text) => color(text, ansi.bold))
251
+ .replace(/`([^`]+)`/g, (_, text) => color(text, ansi.yellow))
252
+ .replace(/~~([^~]+)~~/g, "$1")
253
+ .replace(/\*([^*]+)\*/g, "$1")
254
+ .replace(/_([^_]+)_/g, "$1");
255
+ }
256
+
140
257
  function rolePrefix(name, bgCode) {
141
258
  return `${label(name, bgCode)} ${color("|", bgCode)} `;
142
259
  }
@@ -183,7 +300,10 @@ async function renderLaunchHeader(packageVersion = "") {
183
300
  const title = "AgInTi Flow";
184
301
  const subtitle = "web-first agent workspace";
185
302
  const version = packageVersion ? `v${packageVersion}` : "";
186
- const line = "+--------------------------------------------------+";
303
+ const width = Math.min(Math.max(terminalWidth() - 2, 58), 82);
304
+ const top = `╭${"─".repeat(width)}╮`;
305
+ const mid = `├${"─".repeat(width)}┤`;
306
+ const bottom = `╰${"─".repeat(width)}╯`;
187
307
 
188
308
  if (!useColor || process.env.AGINTIFLOW_NO_ANIMATION === "1") {
189
309
  console.log(` AgInTiFlow ${packageVersion || ""}`.trim());
@@ -198,11 +318,15 @@ async function renderLaunchHeader(packageVersion = "") {
198
318
  output.write(`\r${ansi.clearLine}`);
199
319
  output.write(ansi.cursorShow);
200
320
 
201
- console.log(color(line, "\x1b[38;5;45m"));
202
- console.log(`${color("|", "\x1b[38;5;45m")} ${shimmerText(title, 2)} ${color(version.padStart(36 - title.length), ansi.dim)} ${color("|", "\x1b[38;5;45m")}`);
203
- console.log(`${color("|", "\x1b[38;5;45m")} ${color(subtitle.padEnd(48), ansi.dim)} ${color("|", "\x1b[38;5;45m")}`);
204
- console.log(`${color("|", "\x1b[38;5;45m")} ${color("browser + shell + files + docker + canvas".padEnd(48), ansi.cyan)} ${color("|", "\x1b[38;5;45m")}`);
205
- console.log(color(line, "\x1b[38;5;45m"));
321
+ const border = "\x1b[38;5;45m";
322
+ const titleLine = `${shimmerText(title, 2)} ${color(version, ansi.dim)}`;
323
+ const tagline = "browser + shell + files + docker + web search + scouts";
324
+ console.log(color(top, border));
325
+ console.log(`${color("│", border)} ${padVisible(titleLine, width - 2)} ${color("", border)}`);
326
+ console.log(`${color("│", border)} ${color(padVisible(subtitle, width - 2), ansi.dim)} ${color("│", border)}`);
327
+ console.log(color(mid, border));
328
+ console.log(`${color("│", border)} ${color(padVisible(tagline, width - 2), ansi.cyan)} ${color("│", border)}`);
329
+ console.log(color(bottom, border));
206
330
  }
207
331
 
208
332
  function printHelp() {
@@ -238,24 +362,236 @@ function printHelp() {
238
362
  );
239
363
  }
240
364
 
241
- function renderPromptBuffer(buffer, previousLineCount = 0) {
242
- for (let index = 0; index < previousLineCount; index += 1) {
243
- output.write(`\r${ansi.clearLine}`);
244
- if (index < previousLineCount - 1) output.write("\x1b[1A");
365
+ function logicalLinesWithOffsets(buffer = "") {
366
+ const lines = String(buffer).split("\n");
367
+ let offset = 0;
368
+ return lines.map((line, index) => {
369
+ const start = offset;
370
+ const end = start + line.length;
371
+ offset = end + 1;
372
+ return {
373
+ text: line,
374
+ start,
375
+ end,
376
+ hasNewline: index < lines.length - 1,
377
+ };
378
+ });
379
+ }
380
+
381
+ function promptVisibleWindow(rows, cursorRow, height = terminalHeight()) {
382
+ const maxRows = promptViewportRows(height);
383
+ if (rows.length <= maxRows) {
384
+ return { start: 0, end: rows.length, topHidden: 0, bottomHidden: 0 };
245
385
  }
386
+ const half = Math.floor(maxRows / 2);
387
+ const start = clamp(cursorRow - half, 0, rows.length - maxRows);
388
+ const end = start + maxRows;
389
+ return {
390
+ start,
391
+ end,
392
+ topHidden: start,
393
+ bottomHidden: rows.length - end,
394
+ };
395
+ }
246
396
 
247
- const lines = String(buffer || "").split("\n");
248
- const suggestions = commandSuggestions(lines[0] || "");
249
- const rendered = [];
250
- rendered.push(`${userPrompt().replace(/^\n/, "")}${lines[0] || ""}`);
251
- for (const line of lines.slice(1)) {
252
- rendered.push(`${promptGutter()}${line}`);
397
+ export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth(), height = terminalHeight()) {
398
+ const safeBuffer = String(buffer || "");
399
+ const safeCursor = clamp(Number(cursor) || 0, 0, safeBuffer.length);
400
+ const lineWidth = editorWidth(width);
401
+ const firstPrefix = " user ";
402
+ const nextPrefix = " ... ";
403
+ const firstInnerWidth = Math.max(lineWidth - firstPrefix.length, 8);
404
+ const nextInnerWidth = Math.max(lineWidth - nextPrefix.length, 8);
405
+ const rows = [];
406
+
407
+ for (const [lineIndex, line] of logicalLinesWithOffsets(safeBuffer).entries()) {
408
+ let localOffset = 0;
409
+ const text = line.text;
410
+ if (!text) {
411
+ rows.push({
412
+ prefix: lineIndex === 0 ? firstPrefix : nextPrefix,
413
+ text: "",
414
+ start: line.start,
415
+ end: line.start,
416
+ innerWidth: lineIndex === 0 ? firstInnerWidth : nextInnerWidth,
417
+ lineStart: line.start,
418
+ lineEnd: line.end,
419
+ hasNewline: line.hasNewline,
420
+ });
421
+ continue;
422
+ }
423
+
424
+ while (localOffset < text.length) {
425
+ const prefix = lineIndex === 0 && localOffset === 0 ? firstPrefix : nextPrefix;
426
+ const innerWidth = prefix === firstPrefix ? firstInnerWidth : nextInnerWidth;
427
+ const chunk = text.slice(localOffset, localOffset + innerWidth);
428
+ rows.push({
429
+ prefix,
430
+ text: chunk,
431
+ start: line.start + localOffset,
432
+ end: line.start + localOffset + chunk.length,
433
+ innerWidth,
434
+ lineStart: line.start,
435
+ lineEnd: line.end,
436
+ hasNewline: line.hasNewline,
437
+ });
438
+ localOffset += chunk.length;
439
+ }
440
+ }
441
+
442
+ if (rows.length === 0) {
443
+ rows.push({
444
+ prefix: firstPrefix,
445
+ text: "",
446
+ start: 0,
447
+ end: 0,
448
+ innerWidth: firstInnerWidth,
449
+ lineStart: 0,
450
+ lineEnd: 0,
451
+ hasNewline: false,
452
+ });
453
+ }
454
+
455
+ const last = rows[rows.length - 1];
456
+ if (safeCursor === safeBuffer.length && last.end === safeCursor && last.text.length >= last.innerWidth) {
457
+ rows.push({
458
+ prefix: nextPrefix,
459
+ text: "",
460
+ start: safeCursor,
461
+ end: safeCursor,
462
+ innerWidth: nextInnerWidth,
463
+ lineStart: safeCursor,
464
+ lineEnd: safeCursor,
465
+ hasNewline: false,
466
+ });
467
+ }
468
+
469
+ let cursorRow = rows.length - 1;
470
+ let cursorColumn = rows[cursorRow].prefix.length;
471
+ for (let index = 0; index < rows.length; index += 1) {
472
+ const row = rows[index];
473
+ const next = rows[index + 1];
474
+ if (safeCursor < row.end) {
475
+ cursorRow = index;
476
+ cursorColumn = row.prefix.length + safeCursor - row.start;
477
+ break;
478
+ }
479
+ if (safeCursor === row.end) {
480
+ if (next && next.start === safeCursor && row.text.length >= row.innerWidth) continue;
481
+ cursorRow = index;
482
+ cursorColumn = row.prefix.length + safeCursor - row.start;
483
+ break;
484
+ }
253
485
  }
486
+
487
+ const suggestions = commandSuggestions(safeBuffer.split("\n")[0] || "");
488
+ const emptyHint = "type a request, /help, Enter to send, Ctrl+J for newline";
489
+ const visible = promptVisibleWindow(rows, cursorRow, height);
490
+ const renderedRows = [];
491
+ let renderedCursorRow = cursorRow - visible.start;
492
+
493
+ if (visible.topHidden > 0) {
494
+ renderedRows.push(panelLine(` ... ${visible.topHidden} earlier input row${visible.topHidden === 1 ? "" : "s"}`, ansi.systemBg, lineWidth));
495
+ renderedCursorRow += 1;
496
+ }
497
+
498
+ for (const row of rows.slice(visible.start, visible.end)) {
499
+ const content = safeBuffer ? `${row.prefix}${row.text}` : `${row.prefix}${emptyHint}`;
500
+ renderedRows.push(panelLine(content, ansi.userBg, lineWidth));
501
+ }
502
+
503
+ if (visible.bottomHidden > 0) {
504
+ renderedRows.push(panelLine(` ... ${visible.bottomHidden} later input row${visible.bottomHidden === 1 ? "" : "s"}`, ansi.systemBg, lineWidth));
505
+ }
506
+
254
507
  if (suggestions.length > 0) {
255
- rendered.push(`${promptGutter()}${color(`suggest: ${suggestions.join(" ")}`, ansi.dim)}`);
508
+ renderedRows.push(panelLine(` hint ${suggestions.join(" ")}`, ansi.systemBg, lineWidth));
256
509
  }
257
- output.write(rendered.join("\n"));
258
- return rendered.length;
510
+
511
+ return {
512
+ rows,
513
+ renderedRows,
514
+ cursorRow: renderedCursorRow,
515
+ absoluteCursorRow: cursorRow,
516
+ cursorColumn: clamp(cursorColumn, 0, editorWidth(width) - 1),
517
+ };
518
+ }
519
+
520
+ function cursorLocation(layout, cursor) {
521
+ for (let index = 0; index < layout.rows.length; index += 1) {
522
+ const row = layout.rows[index];
523
+ const next = layout.rows[index + 1];
524
+ if (cursor < row.end) return { rowIndex: index, column: cursor - row.start };
525
+ if (cursor === row.end) {
526
+ if (next && next.start === cursor && row.text.length >= row.innerWidth) continue;
527
+ return { rowIndex: index, column: cursor - row.start };
528
+ }
529
+ }
530
+ const rowIndex = Math.max(layout.rows.length - 1, 0);
531
+ const row = layout.rows[rowIndex];
532
+ return { rowIndex, column: Math.max(row.end - row.start, 0) };
533
+ }
534
+
535
+ function clearRenderedPrompt(previous) {
536
+ if (!previous.lineCount) return;
537
+ const below = previous.lineCount - 1 - previous.cursorRow;
538
+ if (below > 0) output.write(`\x1b[${below}B`);
539
+ output.write(`\r${ansi.clearLine}`);
540
+ for (let index = 1; index < previous.lineCount; index += 1) {
541
+ output.write(`\x1b[1A\r${ansi.clearLine}`);
542
+ }
543
+ }
544
+
545
+ function renderPromptBuffer(buffer, cursor, previous = { lineCount: 0, cursorRow: 0 }) {
546
+ output.write(ansi.cursorHide);
547
+ clearRenderedPrompt(previous);
548
+ const layout = buildPromptLayout(buffer, cursor);
549
+ output.write(layout.renderedRows.join("\n"));
550
+ const below = layout.renderedRows.length - 1 - layout.cursorRow;
551
+ if (below > 0) output.write(`\x1b[${below}A`);
552
+ output.write(`\r\x1b[${layout.cursorColumn + 1}G`);
553
+ output.write(ansi.cursorShow);
554
+ return {
555
+ lineCount: layout.renderedRows.length,
556
+ cursorRow: layout.cursorRow,
557
+ };
558
+ }
559
+
560
+ function moveToPromptBottom(rendered) {
561
+ const below = Math.max((rendered?.lineCount || 1) - 1 - (rendered?.cursorRow || 0), 0);
562
+ if (below > 0) output.write(`\x1b[${below}B`);
563
+ output.write("\r");
564
+ }
565
+
566
+ function lineBounds(buffer, cursor) {
567
+ const safeCursor = clamp(cursor, 0, buffer.length);
568
+ const start = buffer.lastIndexOf("\n", safeCursor - 1) + 1;
569
+ const nextNewline = buffer.indexOf("\n", safeCursor);
570
+ const end = nextNewline === -1 ? buffer.length : nextNewline;
571
+ return { start, end };
572
+ }
573
+
574
+ function insertAt(buffer, cursor, text) {
575
+ return {
576
+ buffer: `${buffer.slice(0, cursor)}${text}${buffer.slice(cursor)}`,
577
+ cursor: cursor + text.length,
578
+ };
579
+ }
580
+
581
+ function removeBefore(buffer, cursor) {
582
+ if (cursor <= 0) return { buffer, cursor };
583
+ return {
584
+ buffer: `${buffer.slice(0, cursor - 1)}${buffer.slice(cursor)}`,
585
+ cursor: cursor - 1,
586
+ };
587
+ }
588
+
589
+ function removeAt(buffer, cursor) {
590
+ if (cursor >= buffer.length) return { buffer, cursor };
591
+ return {
592
+ buffer: `${buffer.slice(0, cursor)}${buffer.slice(cursor + 1)}`,
593
+ cursor,
594
+ };
259
595
  }
260
596
 
261
597
  function createAbortError(message = "Aborted with Ctrl+C") {
@@ -270,34 +606,97 @@ function readTtyPrompt() {
270
606
  emitKeypressEvents(input);
271
607
  const wasRaw = Boolean(input.isRaw);
272
608
  let buffer = "";
273
- let renderedLines = 0;
609
+ let cursor = 0;
610
+ let rendered = { lineCount: 0, cursorRow: 0 };
611
+ let preferredColumn = null;
612
+ let historyIndex = promptHistory.length;
613
+ let draft = "";
614
+ let redrawHandle = null;
274
615
 
275
616
  const cleanup = () => {
617
+ if (redrawHandle) {
618
+ clearImmediate(redrawHandle);
619
+ redrawHandle = null;
620
+ }
276
621
  input.off("keypress", handler);
277
622
  if (typeof input.setRawMode === "function") input.setRawMode(wasRaw);
278
623
  input.pause();
279
624
  output.write(ansi.cursorShow);
280
625
  };
281
626
 
627
+ const renderNow = () => {
628
+ if (redrawHandle) {
629
+ clearImmediate(redrawHandle);
630
+ redrawHandle = null;
631
+ }
632
+ rendered = renderPromptBuffer(buffer, cursor, rendered);
633
+ };
634
+
282
635
  const redraw = () => {
283
- renderedLines = renderPromptBuffer(buffer, renderedLines);
636
+ if (redrawHandle) return;
637
+ redrawHandle = setImmediate(() => {
638
+ redrawHandle = null;
639
+ rendered = renderPromptBuffer(buffer, cursor, rendered);
640
+ });
284
641
  };
285
642
 
286
643
  const submit = () => {
644
+ renderNow();
645
+ moveToPromptBottom(rendered);
287
646
  cleanup();
288
647
  output.write("\n");
648
+ const saved = buffer.trim();
649
+ if (saved && promptHistory[promptHistory.length - 1] !== buffer) promptHistory.push(buffer);
289
650
  resolve(buffer);
290
651
  };
291
652
 
653
+ const setBuffer = (nextBuffer, nextCursor = nextBuffer.length) => {
654
+ buffer = nextBuffer;
655
+ cursor = clamp(nextCursor, 0, buffer.length);
656
+ preferredColumn = null;
657
+ redraw();
658
+ };
659
+
660
+ const moveVertical = (direction) => {
661
+ const layout = buildPromptLayout(buffer, cursor);
662
+ const location = cursorLocation(layout, cursor);
663
+ const targetRowIndex = location.rowIndex + direction;
664
+ if (targetRowIndex < 0) {
665
+ if (promptHistory.length === 0) return;
666
+ if (historyIndex === promptHistory.length) draft = buffer;
667
+ historyIndex = Math.max(historyIndex - 1, 0);
668
+ setBuffer(promptHistory[historyIndex], promptHistory[historyIndex].length);
669
+ return;
670
+ }
671
+ if (targetRowIndex >= layout.rows.length) {
672
+ if (historyIndex < promptHistory.length - 1) {
673
+ historyIndex += 1;
674
+ setBuffer(promptHistory[historyIndex], promptHistory[historyIndex].length);
675
+ } else if (historyIndex < promptHistory.length) {
676
+ historyIndex = promptHistory.length;
677
+ setBuffer(draft, draft.length);
678
+ }
679
+ return;
680
+ }
681
+ const currentColumn = preferredColumn ?? location.column;
682
+ const targetRow = layout.rows[targetRowIndex];
683
+ cursor = targetRow.start + Math.min(currentColumn, targetRow.end - targetRow.start);
684
+ preferredColumn = currentColumn;
685
+ redraw();
686
+ };
687
+
292
688
  const handler = (str = "", key = {}) => {
293
689
  if (key.ctrl && key.name === "c") {
690
+ renderNow();
691
+ moveToPromptBottom(rendered);
294
692
  cleanup();
295
693
  output.write("\n");
296
694
  reject(createAbortError());
297
695
  return;
298
696
  }
299
- if ((key.ctrl && key.name === "j") || (key.sequence === "\n" && key.name !== "return" && key.name !== "enter")) {
300
- buffer += "\n";
697
+ if ((key.ctrl && key.name === "j") || key.sequence === "\n") {
698
+ ({ buffer, cursor } = insertAt(buffer, cursor, "\n"));
699
+ preferredColumn = null;
301
700
  redraw();
302
701
  return;
303
702
  }
@@ -306,7 +705,61 @@ function readTtyPrompt() {
306
705
  return;
307
706
  }
308
707
  if (key.name === "backspace") {
309
- buffer = buffer.slice(0, -1);
708
+ ({ buffer, cursor } = removeBefore(buffer, cursor));
709
+ preferredColumn = null;
710
+ redraw();
711
+ return;
712
+ }
713
+ if (key.name === "delete") {
714
+ ({ buffer, cursor } = removeAt(buffer, cursor));
715
+ preferredColumn = null;
716
+ redraw();
717
+ return;
718
+ }
719
+ if (key.name === "left") {
720
+ cursor = Math.max(cursor - 1, 0);
721
+ preferredColumn = null;
722
+ redraw();
723
+ return;
724
+ }
725
+ if (key.name === "right") {
726
+ cursor = Math.min(cursor + 1, buffer.length);
727
+ preferredColumn = null;
728
+ redraw();
729
+ return;
730
+ }
731
+ if (key.name === "up") {
732
+ moveVertical(-1);
733
+ return;
734
+ }
735
+ if (key.name === "down") {
736
+ moveVertical(1);
737
+ return;
738
+ }
739
+ if ((key.ctrl && key.name === "a") || key.name === "home") {
740
+ cursor = lineBounds(buffer, cursor).start;
741
+ preferredColumn = null;
742
+ redraw();
743
+ return;
744
+ }
745
+ if ((key.ctrl && key.name === "e") || key.name === "end") {
746
+ cursor = lineBounds(buffer, cursor).end;
747
+ preferredColumn = null;
748
+ redraw();
749
+ return;
750
+ }
751
+ if (key.ctrl && key.name === "u") {
752
+ const bounds = lineBounds(buffer, cursor);
753
+ buffer = `${buffer.slice(0, bounds.start)}${buffer.slice(cursor)}`;
754
+ cursor = bounds.start;
755
+ preferredColumn = null;
756
+ redraw();
757
+ return;
758
+ }
759
+ if (key.ctrl && key.name === "k") {
760
+ const bounds = lineBounds(buffer, cursor);
761
+ buffer = `${buffer.slice(0, cursor)}${buffer.slice(bounds.end)}`;
762
+ preferredColumn = null;
310
763
  redraw();
311
764
  return;
312
765
  }
@@ -314,27 +767,32 @@ function readTtyPrompt() {
314
767
  const suggestions = commandSuggestions(buffer.split("\n")[0] || "");
315
768
  if (suggestions.length === 1) {
316
769
  buffer = suggestions[0];
770
+ cursor = buffer.length;
317
771
  }
772
+ preferredColumn = null;
318
773
  redraw();
319
774
  return;
320
775
  }
321
776
  if (key.name === "escape") {
322
777
  buffer = "";
778
+ cursor = 0;
779
+ preferredColumn = null;
323
780
  redraw();
324
781
  return;
325
782
  }
326
783
  if (key.ctrl || key.meta) return;
327
784
  if (str && !key.sequence?.startsWith("\x1b")) {
328
- buffer += str;
785
+ const text = str.replace(/\r/g, "");
786
+ ({ buffer, cursor } = insertAt(buffer, cursor, text));
787
+ preferredColumn = null;
329
788
  redraw();
330
789
  }
331
790
  };
332
791
 
333
792
  input.resume();
334
793
  input.setRawMode(true);
335
- output.write(ansi.cursorHide);
336
794
  input.on("keypress", handler);
337
- redraw();
795
+ renderNow();
338
796
  });
339
797
  }
340
798