min-agent 0.2.0 → 0.3.0

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.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
@@ -1,29 +1,35 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { mkdir, writeFile } from "fs/promises";
3
+ import { existsSync } from "fs";
3
4
  import path from "path";
4
5
  import { confirm, isAutoApprove } from "../confirm.js";
6
+ const MAX_WRITE_BYTES = 10 * 1024 * 1024;
5
7
  export const writeTool = tool({
6
- description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories as needed.",
8
+ description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does (unless `append` is true). Creates parent directories as needed.",
7
9
  inputSchema: jsonSchema({
8
10
  type: "object",
9
11
  properties: {
10
12
  filePath: { type: "string", description: "Path to the file to write (relative to cwd or absolute)" },
11
13
  content: { type: "string", description: "The content to write to the file" },
14
+ append: { type: "boolean", description: "If true, append to the file instead of overwriting" },
12
15
  },
13
16
  required: ["filePath", "content"],
14
17
  }),
15
- execute: async ({ filePath, content }) => {
18
+ execute: async ({ filePath, content, append }) => {
19
+ if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) {
20
+ return `Error: content exceeds ${MAX_WRITE_BYTES} bytes. Write in smaller chunks or check the content size.`;
21
+ }
16
22
  const resolved = path.resolve(process.cwd(), filePath);
17
23
  // Confirm overwriting existing files
18
- if (!isAutoApprove() && existsSync(resolved)) {
24
+ if (!append && !isAutoApprove() && existsSync(resolved)) {
19
25
  const approved = await confirm(`Overwrite existing file: ${filePath}`);
20
26
  if (!approved)
21
27
  return "Write rejected by user.";
22
28
  }
23
29
  try {
24
- mkdirSync(path.dirname(resolved), { recursive: true });
25
- writeFileSync(resolved, content, "utf-8");
26
- return `Written ${content.length} bytes to ${filePath}`;
30
+ await mkdir(path.dirname(resolved), { recursive: true });
31
+ await writeFile(resolved, content, append ? { flag: "a", encoding: "utf-8" } : "utf-8");
32
+ return `Written ${Buffer.byteLength(content, "utf-8")} bytes to ${filePath}${append ? " (appended)" : ""}`;
27
33
  }
28
34
  catch (err) {
29
35
  return `Error writing file: ${err.message}`;
package/dist/tui/App.js CHANGED
@@ -1,15 +1,49 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, useInput } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, useInput, useStdout } from "ink";
3
4
  import { Spinner } from "./Spinner.js";
4
5
  import { InputBar } from "./InputBar.js";
5
6
  import { MessageList } from "./MessageList.js";
6
- import { ConfirmBar } from "./ConfirmBar.js";
7
+ import { ConfirmBar, confirmBarRows } from "./ConfirmBar.js";
8
+ import { QuestionBar, questionBarRows } from "./QuestionBar.js";
7
9
  import { StatusBar } from "./StatusBar.js";
8
- export function App({ initialState, onSubmit, onConfirm, onExit }) {
9
- useInput((_input, key) => {
10
- if (key.escape && initialState.isRunning) {
10
+ import { ModelPicker, modelPickerRows } from "./ModelPicker.js";
11
+ import { setCaretPosition } from "./caret.js";
12
+ import { computeMessageMaxHeight } from "./layout.js";
13
+ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onModelPick, onModelCancel, onExit, onCopyNotice }) {
14
+ // Ink recalculates its own layout on resize without re-rendering React, so
15
+ // track terminal size ourselves to keep heights/widths in sync.
16
+ const { stdout } = useStdout();
17
+ const [terminal, setTerminal] = useState({ columns: stdout.columns || 80, rows: stdout.rows || 0 });
18
+ useEffect(() => {
19
+ const onResize = () => setTerminal({ columns: stdout.columns || 80, rows: stdout.rows || 0 });
20
+ stdout.on("resize", onResize);
21
+ return () => { stdout.off("resize", onResize); };
22
+ }, [stdout]);
23
+ useInput((input, key) => {
24
+ if (key.ctrl && input === "c") {
25
+ onExit();
26
+ return;
27
+ }
28
+ if (key.escape && initialState.isRunning && !initialState.confirmMessage && !initialState.question && !initialState.modelPicker) {
11
29
  onExit();
12
30
  }
13
31
  });
14
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MessageList, { messages: initialState.messages }), initialState.isRunning && (_jsx(Box, { paddingLeft: 1, children: _jsx(Spinner, { label: initialState.spinnerText || "思考中..." }) })), _jsx(StatusBar, { state: initialState }), initialState.confirmMessage ? (_jsx(ConfirmBar, { message: initialState.confirmMessage, onConfirm: onConfirm })) : (_jsx(InputBar, { onSubmit: onSubmit, disabled: initialState.isRunning, placeholder: initialState.isRunning ? "按 Esc 取消运行..." : "输入消息... (Ctrl+J 换行)" }))] }));
32
+ if (initialState.confirmMessage || initialState.question || initialState.modelPicker)
33
+ setCaretPosition(null);
34
+ const columns = terminal.columns;
35
+ const terminalRows = terminal.rows;
36
+ const confirmActive = Boolean(initialState.confirmMessage);
37
+ const questionActive = Boolean(initialState.question);
38
+ const modelPickerActive = Boolean(initialState.modelPicker);
39
+ const footerRows = confirmActive
40
+ ? confirmBarRows(initialState.confirmMessage ?? "", columns)
41
+ : questionActive
42
+ ? questionBarRows(initialState.question.prompt, initialState.question.options, columns)
43
+ : modelPickerActive
44
+ ? modelPickerRows(terminalRows)
45
+ : 3; // input bar: 2 borders + 1 content row
46
+ const spinnerRows = initialState.isRunning && !confirmActive && !questionActive && !modelPickerActive ? 1 : 0;
47
+ const messageMaxHeight = computeMessageMaxHeight(terminalRows, footerRows, spinnerRows);
48
+ return (_jsxs(Box, { flexDirection: "column", height: terminalRows || undefined, children: [!modelPickerActive && (_jsx(Box, { flexGrow: 1, minHeight: 0, children: _jsx(MessageList, { messages: initialState.messages, maxHeight: messageMaxHeight, columns: columns, onCopyNotice: onCopyNotice }) })), initialState.isRunning && !confirmActive && !questionActive && !modelPickerActive && (_jsx(Box, { paddingLeft: 1, children: _jsx(Spinner, { label: initialState.spinnerText || "思考中..." }) })), _jsx(StatusBar, { state: initialState, columns: columns }), initialState.confirmMessage ? (_jsx(ConfirmBar, { message: initialState.confirmMessage, onConfirm: onConfirm })) : initialState.question ? (_jsx(QuestionBar, { prompt: initialState.question.prompt, options: initialState.question.options, onAnswer: onQuestionAnswer })) : initialState.modelPicker ? (_jsx(ModelPicker, { currentModel: initialState.model, onSelect: onModelPick, onCancel: onModelCancel })) : (_jsx(InputBar, { onSubmit: onSubmit, disabled: initialState.isRunning, placeholder: initialState.isRunning ? "按 Esc 取消运行..." : "输入消息... (Ctrl+J 换行)", columns: columns, terminalRows: terminalRows }))] }));
15
49
  }
@@ -1,13 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from "ink";
3
+ import { wrapByWidth } from "./text-width.js";
4
+ import { innerTextWidth } from "./layout.js";
5
+ import { theme } from "./theme.js";
6
+ /**
7
+ * Terminal rows the confirm bar occupies, mirroring the JSX layout below.
8
+ * Needed by App to bound the message area so the frame never exceeds the
9
+ * terminal height while the prompt is up.
10
+ */
11
+ export function confirmBarRows(message, columns) {
12
+ const textWidth = innerTextWidth(columns);
13
+ let rows = 0;
14
+ for (const line of message.split("\n"))
15
+ rows += wrapByWidth(line, textWidth).length;
16
+ return rows + 5; /* borders2 + title1 + marginTop1 + prompt1 */
17
+ }
3
18
  export function ConfirmBar({ message, onConfirm }) {
4
19
  useInput((input, key) => {
5
- if (input.toLowerCase() === "y") {
20
+ if (key.escape) {
21
+ onConfirm(false);
22
+ return;
23
+ }
24
+ const hasEnter = key.return || /[\r\n]/.test(input);
25
+ const stripped = input.replace(/[\r\n]+/g, "").trim().toLowerCase();
26
+ if (stripped === "y") {
6
27
  onConfirm(true);
7
28
  }
8
- else if (input.toLowerCase() === "n" || key.escape || key.return) {
29
+ else if (stripped === "n" || (stripped === "" && hasEnter)) {
9
30
  onConfirm(false);
10
31
  }
11
32
  });
12
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "blue", paddingX: 1, children: [_jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u250C\u2500 \u5373\u5C06\u6267\u884C \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: message }) }), _jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, color: "blue", children: "? \u662F\u5426\u5141\u8BB8\u6267\u884C\uFF1F " }), _jsx(Text, { color: "green", children: "[y]" }), _jsx(Text, { color: "gray", children: " / " }), _jsx(Text, { color: "red", children: "[N]" })] })] }));
33
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.confirmBorder, paddingX: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: theme.confirmBorder, children: "\u26A0 \u8BF7\u6C42\u6267\u884C\u6743\u9650" }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: message }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.confirmBorder, children: "? \u662F\u5426\u5141\u8BB8\u6267\u884C\uFF1F " }), _jsx(Text, { color: theme.success, children: "[y]" }), _jsx(Text, { color: theme.muted, children: " / " }), _jsx(Text, { color: theme.error, children: "[N]" })] })] }));
13
34
  }
@@ -1,83 +1,428 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect } from "react";
3
- import { Box, Text, useInput, useStdin } from "ink";
2
+ import { useState, useEffect, useMemo, useRef, useCallback } from "react";
3
+ import { Box, Text, useInput, useStdin, useStdout } from "ink";
4
+ import { setCaretPosition } from "./caret.js";
5
+ import { displayWidth } from "./text-width.js";
6
+ import { visualRows, moveCaretHorizontal, insertAt, backspaceAt, deleteAt, caretIndexFromClick, scanDeleteKeys, moveToLineStart, moveToLineEnd, deleteToLineStart, deleteToLineEnd, deleteWordBefore } from "./caret-pos.js";
7
+ import { searchHistory } from "./input-history.js";
8
+ import { createHistory, pushInput, browseHistory, resetHistory } from "./input-history.js";
9
+ import { filterSlashCommands, getSlashArgOptions, applySlashArgCompletion } from "./slash-commands.js";
10
+ import { MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js";
11
+ import { scanSgrMouse } from "./mouse.js";
12
+ import { TEXT_START_COLUMN, inputTextWidth } from "./layout.js";
13
+ import { theme } from "./theme.js";
14
+ const GUTTER = "❯ ";
15
+ const SHIFT_ENTER_SEQ = "\x1b[13;2u";
16
+ /**
17
+ * True when `input` looks like the Kitty Shift+Enter sequence
18
+ * ("[13;2u" — the leading ESC is stripped by Ink) or a fragment of it.
19
+ * Fragments may arrive split across stdin chunks; they must never be
20
+ * appended to the input value — the raw stdin listener reassembles them.
21
+ */
22
+ export function isKittySequenceFragment(input) {
23
+ return (/^\[\d{1,2}(;\d{0,2})?u?$/.test(input) || // prefix or complete "[13;2u"
24
+ /^\d{1,2};\d{1,2}u$/.test(input) // suffix after a split ("3;2u")
25
+ );
26
+ }
27
+ /**
28
+ * Accumulate raw stdin bytes and detect the complete Kitty Shift+Enter
29
+ * sequence, which may span multiple chunks. Non-sequence bytes are dropped;
30
+ * the trailing partial prefix (from the last ESC) is kept for the next chunk.
31
+ */
32
+ export function accumulateKittyInput(buffer, chunk) {
33
+ let buf = buffer + chunk;
34
+ let shiftEnter = false;
35
+ let idx;
36
+ while ((idx = buf.indexOf(SHIFT_ENTER_SEQ)) !== -1) {
37
+ buf = buf.slice(idx + SHIFT_ENTER_SEQ.length);
38
+ shiftEnter = true;
39
+ }
40
+ const escIdx = buf.lastIndexOf("\x1b");
41
+ return { buffer: escIdx === -1 ? "" : buf.slice(escIdx), shiftEnter };
42
+ }
4
43
  /**
5
44
  * Multi-line input bar.
6
45
  * - Enter: submit
7
46
  * - Shift+Enter (Kitty protocol terminals): new line
8
47
  * - Ctrl+J: new line (universal fallback)
9
48
  * - Backslash at end + Enter: continue on next line
49
+ *
50
+ * Wrapping is done here rather than by Ink so the caret's row/column is known
51
+ * exactly; the hardware cursor is parked on that cell so IME composition
52
+ * (Chinese, Japanese, Korean) shows up in the right place.
10
53
  */
11
- export function InputBar({ onSubmit, disabled, placeholder }) {
12
- const [value, setValue] = useState("");
13
- const [shiftEnterDetected, setShiftEnterDetected] = useState(false);
54
+ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRows }) {
55
+ const [editing, setEditing] = useState({ value: "", caretIndex: 0 });
56
+ const { value, caretIndex } = editing;
57
+ const [menuIndex, setMenuIndex] = useState(0);
58
+ const [menuDismissed, setMenuDismissed] = useState(false);
14
59
  const { stdin } = useStdin();
15
- // Listen for Kitty protocol Shift+Enter: ESC[13;2u
16
- useEffect(() => {
17
- if (!stdin || disabled)
18
- return;
19
- const handleData = (data) => {
20
- const str = data.toString("utf-8");
21
- // Kitty keyboard protocol: \x1b[13;2u = Shift+Enter
22
- if (str.includes("\x1b[13;2u")) {
23
- setShiftEnterDetected(true);
24
- setValue((v) => v + "\n");
25
- }
26
- };
27
- stdin.on("data", handleData);
28
- return () => { stdin.off("data", handleData); };
29
- }, [stdin, disabled]);
60
+ const { stdout } = useStdout();
61
+ const kittyBuffer = useRef("");
62
+ const mouseBuffer = useRef("");
63
+ const deleteBuffer = useRef("");
64
+ const width = columns || 80;
65
+ const [historyState, setHistoryState] = useState(() => createHistory());
66
+ const historyRef = useRef(historyState);
67
+ // The ref mirrors `editing` synchronously, so edits from separate stdin
68
+ // listeners (Ink's useInput for text, the raw listener for delete keys)
69
+ // always apply to the newest state, in arrival order, even when React has
70
+ // not re-rendered yet (fast key repeats, text + delete in one chunk).
71
+ const editingRef = useRef(editing);
72
+ const applyEdit = useCallback((edit) => {
73
+ const after = edit(editingRef.current.value, editingRef.current.caretIndex);
74
+ editingRef.current = after;
75
+ setEditing(after);
76
+ const nextHistory = resetHistory(historyRef.current, after.value);
77
+ if (nextHistory !== historyRef.current) {
78
+ historyRef.current = nextHistory;
79
+ setHistoryState(nextHistory);
80
+ }
81
+ }, []);
82
+ const submit = (text) => {
83
+ historyRef.current = pushInput(historyRef.current, text);
84
+ setHistoryState(historyRef.current);
85
+ onSubmit(text);
86
+ };
87
+ const browseTo = (direction) => {
88
+ const { text, state } = browseHistory(historyRef.current, direction);
89
+ historyRef.current = state;
90
+ setHistoryState(state);
91
+ if (text !== editingRef.current.value) {
92
+ const after = { value: text, caretIndex: Array.from(text).length };
93
+ editingRef.current = after;
94
+ setEditing(after);
95
+ }
96
+ };
97
+ // Release the caret when the input bar goes away (e.g. confirm prompt).
98
+ useEffect(() => () => { setCaretPosition(null); }, []);
99
+ // Slash command menu — closes once a space follows the command name so the
100
+ // argument menu can take over immediately.
101
+ const matches = useMemo(() => filterSlashCommands(value), [value]);
102
+ const menuOpen = value.startsWith("/") && !/\s/.test(value.slice(1)) && !menuDismissed && matches.length > 0;
103
+ const shownIndex = menuOpen ? Math.min(menuIndex, matches.length - 1) : 0;
104
+ const selected = menuOpen ? matches[shownIndex] : undefined;
105
+ // Argument completion (e.g. `/skills disable <name>`), fed by app-registered providers
106
+ const argOptions = useMemo(() => (menuOpen ? [] : getSlashArgOptions(value)), [value, menuOpen]);
107
+ const argMenuOpen = !menuOpen && !menuDismissed && argOptions.length > 0;
108
+ const argIndex = argMenuOpen ? Math.min(menuIndex, argOptions.length - 1) : 0;
109
+ const argSelected = argMenuOpen ? argOptions[argIndex] : undefined;
110
+ useEffect(() => { setMenuIndex(0); }, [value]);
111
+ const acceptCommand = (cmd) => {
112
+ if (cmd.argHint) {
113
+ applyEdit(() => ({ value: `/${cmd.name} `, caretIndex: cmd.name.length + 2 }));
114
+ }
115
+ else {
116
+ submit(`/${cmd.name}`);
117
+ applyEdit(() => ({ value: "", caretIndex: 0 }));
118
+ }
119
+ };
120
+ const acceptArg = (option) => {
121
+ applyEdit((v) => {
122
+ const next = applySlashArgCompletion(v, option.value);
123
+ return { value: next, caretIndex: Array.from(next).length };
124
+ });
125
+ };
30
126
  useInput((input, key) => {
31
127
  if (disabled)
32
128
  return;
33
- // If we just handled a Shift+Enter via raw stdin, skip this cycle
34
- if (shiftEnterDetected) {
35
- setShiftEnterDetected(false);
129
+ // Fast typing or paste can deliver "text\r" as a single chunk — treat a
130
+ // trailing Enter as Enter and strip it from the value.
131
+ const cleanInput = input.replace(/[\r\n]+$/, "");
132
+ const pressedEnter = key.return || /[\r\n]+$/.test(input);
133
+ // Esc closes the slash menu; while browsing history it returns to the draft
134
+ if (key.escape) {
135
+ if (menuOpen || argMenuOpen)
136
+ setMenuDismissed(true);
137
+ else if (historyRef.current.index !== -1) {
138
+ const next = resetHistory(historyRef.current, historyRef.current.draft);
139
+ historyRef.current = next;
140
+ setHistoryState(next);
141
+ if (next.draft !== editingRef.current.value) {
142
+ const after = { value: next.draft, caretIndex: Array.from(next.draft).length };
143
+ editingRef.current = after;
144
+ setEditing(after);
145
+ }
146
+ }
147
+ return;
148
+ }
149
+ // Any other key re-opens the menu
150
+ setMenuDismissed(false);
151
+ // Slash menu navigation
152
+ if (menuOpen) {
153
+ if (key.downArrow) {
154
+ setMenuIndex((i) => (i + 1) % matches.length);
155
+ return;
156
+ }
157
+ if (key.upArrow) {
158
+ setMenuIndex((i) => (i - 1 + matches.length) % matches.length);
159
+ return;
160
+ }
161
+ if (pressedEnter || key.tab) {
162
+ if (selected)
163
+ acceptCommand(selected);
164
+ return;
165
+ }
166
+ // Left/right are ignored while the menu is open
167
+ if (key.leftArrow || key.rightArrow)
168
+ return;
169
+ }
170
+ // Argument menu: ↑↓ pick, Tab completes. Enter still submits the line so a
171
+ // fully typed command is never swallowed by the suggestion list.
172
+ if (argMenuOpen) {
173
+ if (key.downArrow) {
174
+ setMenuIndex((i) => (i + 1) % argOptions.length);
175
+ return;
176
+ }
177
+ if (key.upArrow) {
178
+ setMenuIndex((i) => (i - 1 + argOptions.length) % argOptions.length);
179
+ return;
180
+ }
181
+ if (key.tab) {
182
+ if (argSelected)
183
+ acceptArg(argSelected);
184
+ return;
185
+ }
186
+ }
187
+ // Input history: ↑ older, ↓ newer (only when the slash menu is closed)
188
+ if (key.upArrow) {
189
+ browseTo(1);
190
+ return;
191
+ }
192
+ if (key.downArrow) {
193
+ browseTo(-1);
36
194
  return;
37
195
  }
38
- // Ctrl+J: insert newline
39
- if (key.ctrl && input === "j") {
40
- setValue((v) => v + "\n");
196
+ // Ctrl+J: insert newline. The terminal sends LF (0x0A) for Ctrl+J and CR
197
+ // (0x0D) for Enter; Ink parses both as "enter", but key.return is only
198
+ // true for the CR case, so a lone "\n" without key.return is Ctrl+J.
199
+ if (input === "\n" && !key.return) {
200
+ applyEdit((v, c) => insertAt(v, c, "\n"));
41
201
  return;
42
202
  }
43
203
  // Enter: submit or continue if ends with backslash
44
- if (key.return) {
45
- if (value.endsWith("\\")) {
46
- setValue((v) => v.slice(0, -1) + "\n");
204
+ if (pressedEnter) {
205
+ const v = editingRef.current.value;
206
+ if (v.endsWith("\\")) {
207
+ applyEdit((cv, cc) => insertAt(cv.slice(0, -1), cc, "\n"));
47
208
  return;
48
209
  }
49
- const text = value.trim();
50
- if (text) {
51
- onSubmit(text);
52
- setValue("");
210
+ const text = v.trim();
211
+ if (text)
212
+ submit(text);
213
+ applyEdit(() => ({ value: "", caretIndex: 0 }));
214
+ return;
215
+ }
216
+ // Left / right move the caret through the text
217
+ if (key.leftArrow) {
218
+ applyEdit((v, c) => ({ value: v, caretIndex: moveCaretHorizontal(v, c, -1) }));
219
+ return;
220
+ }
221
+ if (key.rightArrow) {
222
+ applyEdit((v, c) => ({ value: v, caretIndex: moveCaretHorizontal(v, c, 1) }));
223
+ return;
224
+ }
225
+ // Backspace (\b, Ctrl+H) — remove the code point before the caret. Most
226
+ // terminals send DEL (0x7F) for the Backspace key and CSI 3 ~ for Delete;
227
+ // Ink reports both of those as key.delete (not key.backspace), so they
228
+ // never reach this branch and are handled by the raw stdin listener,
229
+ // which can tell them apart.
230
+ if (key.backspace) {
231
+ applyEdit((v, c) => backspaceAt(v, c));
232
+ return;
233
+ }
234
+ if (key.escape || key.pageUp || key.pageDown)
235
+ return;
236
+ // Ctrl+R: history search by current input
237
+ if (key.ctrl && input === "r") {
238
+ const hit = searchHistory(historyRef.current, editingRef.current.value);
239
+ if (hit) {
240
+ const after = { value: hit, caretIndex: Array.from(hit).length };
241
+ editingRef.current = after;
242
+ setEditing(after);
53
243
  }
54
244
  return;
55
245
  }
56
- // Backspace / Delete
57
- if (key.backspace || key.delete) {
58
- setValue((v) => v.slice(0, -1));
246
+ // Readline keys
247
+ if (key.ctrl && input === "a") {
248
+ applyEdit((v, c) => ({ value: v, caretIndex: moveToLineStart(v, c) }));
59
249
  return;
60
250
  }
61
- // Escape, arrows, etc. ignore (handled elsewhere)
62
- if (key.escape || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
251
+ if (key.ctrl && input === "e") {
252
+ applyEdit((v, c) => ({ value: v, caretIndex: moveToLineEnd(v, c) }));
63
253
  return;
64
- if (key.pageUp || key.pageDown)
254
+ }
255
+ if (key.ctrl && input === "k") {
256
+ applyEdit((v, c) => deleteToLineEnd(v, c));
257
+ return;
258
+ }
259
+ if (key.ctrl && input === "u") {
260
+ applyEdit((v, c) => deleteToLineStart(v, c));
261
+ return;
262
+ }
263
+ if (key.ctrl && input === "w") {
264
+ applyEdit((v, c) => deleteWordBefore(v, c));
265
+ return;
266
+ }
267
+ if (key.meta && input === "b") {
268
+ applyEdit((v, c) => ({ value: v, caretIndex: Math.max(0, c - 1) }));
269
+ return;
270
+ }
271
+ if (key.meta && input === "f") {
272
+ applyEdit((v, c) => ({ value: v, caretIndex: Math.min(Array.from(v).length, c + 1) }));
65
273
  return;
274
+ }
66
275
  if (key.ctrl || key.meta)
67
276
  return;
277
+ // Kitty Shift+Enter sequence (or a fragment of it) — handled by the raw
278
+ // stdin listener, never as text
279
+ if (isKittySequenceFragment(input))
280
+ return;
281
+ if (/^\[<.*[Mm]$/.test(input) || input.startsWith("[<") || /^[\d;,]+[Mm]$/.test(input))
282
+ return;
283
+ // Bracketed paste: ESC[200~ ... ESC[200~ — Ink may deliver without ESC, treat as bulk insert
284
+ if (input.includes("\x1b[200~") || input.includes("[200~")) {
285
+ const cleaned = input.replace(/\x1b?\[200~|\x1b?\[201~/g, "");
286
+ if (cleaned)
287
+ applyEdit((v, c) => insertAt(v, c, cleaned));
288
+ return;
289
+ }
68
290
  // Tab → 2 spaces
69
291
  if (key.tab) {
70
- setValue((v) => v + " ");
292
+ applyEdit((v, c) => insertAt(v, c, " "));
71
293
  return;
72
294
  }
73
295
  // Normal character input (including CJK)
74
296
  if (input) {
75
- setValue((v) => v + input);
297
+ applyEdit((v, c) => insertAt(v, c, cleanInput));
76
298
  }
77
299
  }, { isActive: !disabled });
78
300
  const isEmpty = value === "";
79
- const lines = value.split("\n");
80
- const isMultiline = lines.length > 1;
81
- const borderColor = disabled ? "gray" : "cyan";
82
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: borderColor, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: disabled ? "gray" : "cyan", children: "\u276F " }), isEmpty && placeholder ? (_jsx(Text, { color: "gray", dimColor: true, children: placeholder })) : (_jsxs(Text, { wrap: "wrap", children: [value, "\u2588"] }))] }), isMultiline && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { color: "gray", dimColor: true, children: [lines.length, " \u884C | Shift+Enter/Ctrl+J \u6362\u884C | Enter \u53D1\u9001"] }) }))] }));
301
+ const logicalLines = value.split("\n");
302
+ const isMultiline = logicalLines.length > 1;
303
+ const borderColor = disabled ? theme.inputBorderDisabled : theme.inputBorder;
304
+ const textWidth = inputTextWidth(width);
305
+ // Visual rows of the value, plus the caret position inside them.
306
+ const rows = visualRows(value, textWidth);
307
+ const clampedCaret = Math.min(caretIndex, Array.from(value).length);
308
+ // Locate the caret's visual row and its character offset inside that row.
309
+ let caretRowIndex = rows.length - 1;
310
+ let caretCharOffset = Array.from(rows[caretRowIndex].text).length;
311
+ for (let i = 0; i < rows.length; i++) {
312
+ const row = rows[i];
313
+ const end = row.startIndex + Array.from(row.text).length;
314
+ if (clampedCaret >= row.startIndex && clampedCaret <= end) {
315
+ caretRowIndex = i;
316
+ caretCharOffset = clampedCaret - row.startIndex;
317
+ break;
318
+ }
319
+ }
320
+ // A caret at the end of a full row has no cell left — move it to the start
321
+ // of the next visual row (pushing an empty one when it is the last row).
322
+ let caretColumnOffset = displayWidth(Array.from(rows[caretRowIndex].text).slice(0, caretCharOffset).join(""));
323
+ if (caretColumnOffset >= textWidth) {
324
+ if (caretRowIndex === rows.length - 1) {
325
+ rows.push({
326
+ text: "",
327
+ startIndex: rows[caretRowIndex].startIndex + Array.from(rows[caretRowIndex].text).length,
328
+ });
329
+ }
330
+ caretRowIndex++;
331
+ caretCharOffset = 0;
332
+ caretColumnOffset = 0;
333
+ }
334
+ // The caret may sit on any content row; the only thing below it is the
335
+ // (single-row) multi-line hint — plus the slash menu when it floats below
336
+ // the input bar.
337
+ const hintRows = isMultiline ? 1 : 0;
338
+ const menuRows = menuOpen ? matches.length + 5 /* margin + borders + header + footer */ : 0;
339
+ // Input bar sits at the bottom of App's footer: its content area starts
340
+ // `terminalRows` rows up minus the bar's own height (menu + bottom border +
341
+ // hint above it in screen order, from the bottom of the terminal up).
342
+ const inputTopRow = terminalRows - rows.length - hintRows - menuRows;
343
+ // Latest geometry for the raw stdin listener, which subscribes only once
344
+ // (independent of re-renders) and needs these when a click arrives.
345
+ const rowsRef = useRef(rows);
346
+ rowsRef.current = rows;
347
+ const inputTopRowRef = useRef(inputTopRow);
348
+ inputTopRowRef.current = inputTopRow;
349
+ // Listen for Kitty protocol Shift+Enter: ESC[13;2u, which may arrive split
350
+ // across stdin chunks — accumulate and reassemble here. Ink's useInput gets
351
+ // each raw chunk and would append fragments to the value, so isKittySequenceFragment
352
+ // below filters them out. Backspace (DEL 0x7F) and Delete (CSI 3 ~ and
353
+ // variants) are also picked out of the same raw stream, since Ink collapses
354
+ // them into one key.delete. SGR mouse click events arrive on the same raw
355
+ // stream; parse them and move the caret to the clicked cell. The listener
356
+ // subscribes once; every edit goes through applyEdit, which reads the
357
+ // synchronous editingRef, so fast key repeats never operate on stale text.
358
+ useEffect(() => {
359
+ if (!stdin || disabled)
360
+ return;
361
+ const handleData = (data) => {
362
+ const chunk = data.toString("utf-8");
363
+ // Kitty Shift+Enter accumulation
364
+ const { buffer: kittyBuf, shiftEnter } = accumulateKittyInput(kittyBuffer.current, chunk);
365
+ kittyBuffer.current = kittyBuf;
366
+ if (shiftEnter)
367
+ applyEdit((v, c) => insertAt(v, c, "\n"));
368
+ // Backspace (DEL 0x7F) and Delete (CSI 3 ~ / variants): Ink reports
369
+ // them all as key.delete, so they are distinguished and applied here.
370
+ // The buffer handles a sequence split across chunks.
371
+ const { buffer: delBuf, actions } = scanDeleteKeys(deleteBuffer.current, chunk);
372
+ deleteBuffer.current = delBuf;
373
+ if (actions.length > 0) {
374
+ for (const action of actions) {
375
+ applyEdit((v, c) => (action.kind === "backspace" ? backspaceAt(v, c) : deleteAt(v, c)));
376
+ }
377
+ }
378
+ mouseBuffer.current += chunk;
379
+ let sgr;
380
+ while ((sgr = scanSgrMouse(mouseBuffer.current))) {
381
+ mouseBuffer.current = sgr.rest;
382
+ if (sgr.event.button === 0 && sgr.event.press) {
383
+ const contentRow = sgr.event.row - inputTopRowRef.current;
384
+ if (contentRow >= 0 && contentRow < rowsRef.current.length) {
385
+ const evCol = sgr.event.col;
386
+ applyEdit((v) => ({
387
+ value: v,
388
+ caretIndex: caretIndexFromClick(rowsRef.current, contentRow, evCol, TEXT_START_COLUMN),
389
+ }));
390
+ }
391
+ }
392
+ }
393
+ if (mouseBuffer.current.length > 64) {
394
+ const esc = mouseBuffer.current.lastIndexOf("\x1b");
395
+ mouseBuffer.current = esc === -1 ? "" : mouseBuffer.current.slice(esc);
396
+ }
397
+ };
398
+ stdin.on("data", handleData);
399
+ return () => { stdin.off("data", handleData); };
400
+ }, [stdin, disabled, applyEdit]);
401
+ // Enable the SGR mouse protocol so clicks on the input bar can position the
402
+ // caret. Events arrive on raw stdin; parseSgrMouse picks them out of the
403
+ // same chunk stream as the Kitty sequence. Terminals without mouse support
404
+ // simply never send events — keyboard navigation still works.
405
+ useEffect(() => {
406
+ stdout.write(MOUSE_ENABLE);
407
+ return () => { stdout.write(MOUSE_DISABLE); };
408
+ }, [stdout]);
409
+ // Must publish during render (not in effect) — Ink writes the frame in
410
+ // resetAfterCommit before effects run. If delayed to useEffect, the
411
+ // hardware cursor (IME preedit anchor) lags by one frame and appears
412
+ // displaced while composing CJK.
413
+ {
414
+ const rowsBelowCaret = rows.length - 1 - caretRowIndex;
415
+ setCaretPosition({
416
+ rowsAbove: rowsBelowCaret + 1 /* bottom border */ + hintRows + menuRows,
417
+ column: TEXT_START_COLUMN + caretColumnOffset,
418
+ });
419
+ }
420
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: borderColor, paddingX: 1, children: [isEmpty && placeholder ? (_jsxs(Box, { children: [_jsx(Text, { color: disabled ? theme.inputBorderDisabled : theme.inputBorder, children: GUTTER }), _jsx(Text, { inverse: true, children: " " }), _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: placeholder })] })) : (rows.map((row, i) => {
421
+ const chars = Array.from(row.text);
422
+ const caretHere = i === caretRowIndex;
423
+ const offset = caretHere ? caretCharOffset : chars.length;
424
+ const before = chars.slice(0, offset).join("");
425
+ const after = chars.slice(offset).join("");
426
+ return (_jsxs(Box, { children: [_jsx(Text, { color: disabled ? theme.inputBorderDisabled : theme.inputBorder, children: i === 0 ? GUTTER : " " }), _jsxs(Text, { wrap: "truncate-end", children: [before, caretHere && _jsx(Text, { inverse: true, children: " " }), after] })] }, i));
427
+ })), isMultiline && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [logicalLines.length, " \u884C | Shift+Enter/Ctrl+J \u6362\u884C | Enter \u53D1\u9001"] }) }))] }), menuOpen && selected && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.inputBorder, paddingX: 1, marginTop: 1, children: [_jsx(Box, { children: _jsx(Text, { color: theme.muted, children: value.trim() === "/" ? "命令" : `/${value.slice(1)} 命令` }) }), matches.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === shownIndex ? theme.accent : theme.muted, children: i === shownIndex ? "❯ " : " " }), _jsxs(Text, { bold: i === shownIndex, color: i === shownIndex ? theme.accent : undefined, children: ["/", c.name, c.argHint ? ` ${c.argHint}` : ""] }), _jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [" ", c.description] })] }, c.name))), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193 \u9009\u62E9 \u00B7 Enter \u6267\u884C \u00B7 Esc \u5173\u95ED" }) })] })), argMenuOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.inputBorder, paddingX: 1, marginTop: 1, children: [argOptions.slice(0, 8).map((o, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === argIndex ? theme.accent : theme.muted, children: i === argIndex ? "❯ " : " " }), _jsx(Text, { bold: i === argIndex, color: i === argIndex ? theme.accent : undefined, children: o.value }), o.description && (_jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [" ", o.description] }))] }, o.value))), _jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: [argOptions.length > 8 ? `还有 ${argOptions.length - 8} 项 · ` : "", "\u2191\u2193 \u9009\u62E9 \u00B7 Tab \u8865\u5168 \u00B7 Enter \u6267\u884C \u00B7 Esc \u5173\u95ED"] }) })] }))] }));
83
428
  }