min-agent 0.2.1 → 0.4.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 (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
@@ -1,83 +1,543 @@
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, moveCaretVertical, insertAt, backspaceAt, deleteAt, caretIndexFromClick, scanDeleteKeys, scanHomeEndKeys, moveToLineStart, moveToLineEnd, deleteToLineStart, deleteToLineEnd, deleteWordBefore, } from "./caret-pos.js";
7
+ import { searchHistory, loadInputHistory, saveInputHistory, createHistory, pushInput, browseHistory, resetHistory, isCtrlC, clearInputOnCtrlC, EXIT_CTRL_C_WINDOW_MS, } from "./input-history.js";
8
+ import { filterSlashCommands, getSlashArgOptions, applySlashArgCompletion, isKnownSlashCommandInput, } from "./slash-commands.js";
9
+ import { MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js";
10
+ import { scanSgrMouse } from "./mouse.js";
11
+ import { TEXT_START_COLUMN, inputTextWidth } from "./layout.js";
12
+ import { theme } from "./theme.js";
13
+ const GUTTER = "❯ ";
14
+ const SHIFT_ENTER_SEQ = "\x1b[13;2u";
15
+ /**
16
+ * True when `input` looks like the Kitty Shift+Enter sequence
17
+ * ("[13;2u" — the leading ESC is stripped by Ink) or a fragment of it.
18
+ * Fragments may arrive split across stdin chunks; they must never be
19
+ * appended to the input value — the raw stdin listener reassembles them.
20
+ */
21
+ export function isKittySequenceFragment(input) {
22
+ return (/^\[\d{1,2}(;\d{0,2})?u?$/.test(input) || // prefix or complete "[13;2u"
23
+ /^\d{1,2};\d{1,2}u$/.test(input) // suffix after a split ("3;2u")
24
+ );
25
+ }
26
+ /**
27
+ * Accumulate raw stdin bytes and detect the complete Kitty Shift+Enter
28
+ * sequence, which may span multiple chunks. Non-sequence bytes are dropped;
29
+ * the trailing partial prefix (from the last ESC) is kept for the next chunk.
30
+ */
31
+ export function accumulateKittyInput(buffer, chunk) {
32
+ let buf = buffer + chunk;
33
+ let shiftEnter = false;
34
+ let idx;
35
+ while ((idx = buf.indexOf(SHIFT_ENTER_SEQ)) !== -1) {
36
+ buf = buf.slice(idx + SHIFT_ENTER_SEQ.length);
37
+ shiftEnter = true;
38
+ }
39
+ const escIdx = buf.lastIndexOf("\x1b");
40
+ return { buffer: escIdx === -1 ? "" : buf.slice(escIdx), shiftEnter };
41
+ }
4
42
  /**
5
43
  * Multi-line input bar.
6
44
  * - Enter: submit
7
45
  * - Shift+Enter (Kitty protocol terminals): new line
8
46
  * - Ctrl+J: new line (universal fallback)
9
47
  * - Backslash at end + Enter: continue on next line
48
+ *
49
+ * Wrapping is done here rather than by Ink so the caret's row/column is known
50
+ * exactly; the hardware cursor is parked on that cell so IME composition
51
+ * (Chinese, Japanese, Korean) shows up in the right place.
10
52
  */
11
- export function InputBar({ onSubmit, disabled, placeholder }) {
12
- const [value, setValue] = useState("");
13
- const [shiftEnterDetected, setShiftEnterDetected] = useState(false);
53
+ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRows, onSelectionMode, onExit, }) {
54
+ const [editing, setEditing] = useState({ value: "", caretIndex: 0 });
55
+ const { value, caretIndex } = editing;
56
+ const [menuIndex, setMenuIndex] = useState(0);
57
+ const [menuDismissed, setMenuDismissed] = useState(false);
14
58
  const { stdin } = useStdin();
15
- // Listen for Kitty protocol Shift+Enter: ESC[13;2u
16
- useEffect(() => {
17
- if (!stdin || disabled)
59
+ const { stdout } = useStdout();
60
+ const kittyBuffer = useRef("");
61
+ const mouseBuffer = useRef("");
62
+ const deleteBuffer = useRef("");
63
+ const homeEndBuffer = useRef("");
64
+ const width = columns || 80;
65
+ const [historyState, setHistoryState] = useState(() => createHistory(loadInputHistory()));
66
+ const historyRef = useRef(historyState);
67
+ const [exitHint, setExitHint] = useState(false);
68
+ const exitArmedAtRef = useRef(null);
69
+ const exitHintTimer = useRef(null);
70
+ const clearExitHint = useCallback(() => {
71
+ if (exitArmedAtRef.current === null && exitHintTimer.current === null)
18
72
  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]);
73
+ if (exitHintTimer.current !== null) {
74
+ clearTimeout(exitHintTimer.current);
75
+ exitHintTimer.current = null;
76
+ }
77
+ exitArmedAtRef.current = null;
78
+ setExitHint(false);
79
+ }, []);
80
+ const showExitHint = useCallback((armedAt) => {
81
+ exitArmedAtRef.current = armedAt;
82
+ setExitHint(true);
83
+ if (exitHintTimer.current !== null)
84
+ clearTimeout(exitHintTimer.current);
85
+ exitHintTimer.current = setTimeout(() => {
86
+ exitHintTimer.current = null;
87
+ exitArmedAtRef.current = null;
88
+ setExitHint(false);
89
+ }, EXIT_CTRL_C_WINDOW_MS);
90
+ }, []);
91
+ useEffect(() => () => clearExitHint(), [clearExitHint]);
92
+ // The ref mirrors `editing` synchronously, so edits from separate stdin
93
+ // listeners (Ink's useInput for text, the raw listener for delete keys)
94
+ // always apply to the newest state, in arrival order, even when React has
95
+ // not re-rendered yet (fast key repeats, text + delete in one chunk).
96
+ const editingRef = useRef(editing);
97
+ const applyEdit = useCallback((edit) => {
98
+ clearExitHint();
99
+ const after = edit(editingRef.current.value, editingRef.current.caretIndex);
100
+ editingRef.current = after;
101
+ setEditing(after);
102
+ const nextHistory = resetHistory(historyRef.current, after.value);
103
+ if (nextHistory !== historyRef.current) {
104
+ historyRef.current = nextHistory;
105
+ setHistoryState(nextHistory);
106
+ }
107
+ }, [clearExitHint]);
108
+ const submit = (text) => {
109
+ historyRef.current = pushInput(historyRef.current, text);
110
+ setHistoryState(historyRef.current);
111
+ saveInputHistory(historyRef.current.entries);
112
+ onSubmit(text);
113
+ };
114
+ const browseTo = (direction) => {
115
+ const { text, state } = browseHistory(historyRef.current, direction, isKnownSlashCommandInput);
116
+ historyRef.current = state;
117
+ setHistoryState(state);
118
+ if (text !== editingRef.current.value) {
119
+ const after = { value: text, caretIndex: Array.from(text).length };
120
+ editingRef.current = after;
121
+ setEditing(after);
122
+ }
123
+ };
124
+ // Release the caret when the input bar goes away (e.g. confirm prompt).
125
+ useEffect(() => () => {
126
+ setCaretPosition(null);
127
+ }, []);
128
+ // Slash command menu — closes once a space follows the command name so the
129
+ // argument menu can take over immediately.
130
+ const matches = useMemo(() => filterSlashCommands(value), [value]);
131
+ const menuOpen = value.startsWith("/") && !/\s/.test(value.slice(1)) && !menuDismissed && matches.length > 0;
132
+ const shownIndex = menuOpen ? Math.min(menuIndex, matches.length - 1) : 0;
133
+ const selected = menuOpen ? matches[shownIndex] : undefined;
134
+ // Argument completion (e.g. `/skills disable <name>`), fed by app-registered providers
135
+ const argOptions = useMemo(() => (menuOpen ? [] : getSlashArgOptions(value)), [value, menuOpen]);
136
+ const argMenuOpen = !menuOpen && !menuDismissed && argOptions.length > 0;
137
+ const argIndex = argMenuOpen ? Math.min(menuIndex, argOptions.length - 1) : 0;
138
+ const argSelected = argMenuOpen ? argOptions[argIndex] : undefined;
139
+ useEffect(() => {
140
+ setMenuIndex(0);
141
+ }, []);
142
+ const acceptCommand = (cmd) => {
143
+ if (cmd.argHint) {
144
+ applyEdit(() => ({ value: `/${cmd.name} `, caretIndex: cmd.name.length + 2 }));
145
+ }
146
+ else {
147
+ submit(`/${cmd.name}`);
148
+ applyEdit(() => ({ value: "", caretIndex: 0 }));
149
+ }
150
+ };
151
+ const acceptArg = (option) => {
152
+ applyEdit((v) => {
153
+ const next = applySlashArgCompletion(v, option.value);
154
+ return { value: next, caretIndex: Array.from(next).length };
155
+ });
156
+ };
157
+ const acceptCommandRef = useRef(acceptCommand);
158
+ acceptCommandRef.current = acceptCommand;
159
+ const acceptArgRef = useRef(acceptArg);
160
+ acceptArgRef.current = acceptArg;
161
+ const matchesRef = useRef(matches);
162
+ matchesRef.current = matches;
163
+ const argOptionsRef = useRef(argOptions);
164
+ argOptionsRef.current = argOptions;
165
+ const menuOpenRef = useRef(menuOpen);
166
+ menuOpenRef.current = menuOpen;
167
+ const argMenuOpenRef = useRef(argMenuOpen);
168
+ argMenuOpenRef.current = argMenuOpen;
30
169
  useInput((input, key) => {
31
170
  if (disabled)
32
171
  return;
33
- // If we just handled a Shift+Enter via raw stdin, skip this cycle
34
- if (shiftEnterDetected) {
35
- setShiftEnterDetected(false);
172
+ if (isCtrlC(input, key)) {
173
+ const current = editingRef.current.value;
174
+ const result = clearInputOnCtrlC(historyRef.current, current, exitArmedAtRef.current, Date.now());
175
+ if (result.exit) {
176
+ clearExitHint();
177
+ onExit();
178
+ return;
179
+ }
180
+ if (result.state.entries !== historyRef.current.entries) {
181
+ saveInputHistory(result.state.entries);
182
+ }
183
+ historyRef.current = result.state;
184
+ setHistoryState(result.state);
185
+ if (result.armedAt !== null) {
186
+ showExitHint(result.armedAt);
187
+ return;
188
+ }
189
+ clearExitHint();
190
+ applyEdit(() => ({ value: "", caretIndex: 0 }));
191
+ return;
192
+ }
193
+ clearExitHint();
194
+ if (key.ctrl && input === "b") {
195
+ onSelectionMode?.();
196
+ return;
197
+ }
198
+ // Fast typing or paste can deliver "text\r" as a single chunk — treat a
199
+ // trailing Enter as Enter and strip it from the value.
200
+ const cleanInput = input.replace(/[\r\n]+$/, "");
201
+ const pressedEnter = key.return || /[\r\n]+$/.test(input);
202
+ // Esc closes the slash menu; while browsing history it returns to the draft
203
+ if (key.escape) {
204
+ if (menuOpen || argMenuOpen)
205
+ setMenuDismissed(true);
206
+ else if (historyRef.current.index !== -1) {
207
+ const next = resetHistory(historyRef.current, historyRef.current.draft);
208
+ historyRef.current = next;
209
+ setHistoryState(next);
210
+ if (next.draft !== editingRef.current.value) {
211
+ const after = { value: next.draft, caretIndex: Array.from(next.draft).length };
212
+ editingRef.current = after;
213
+ setEditing(after);
214
+ }
215
+ }
216
+ return;
217
+ }
218
+ // Any other key re-opens the menu
219
+ setMenuDismissed(false);
220
+ // Slash menu: Tab/Shift+Tab pick, Enter runs. ↑↓ always recall sent input.
221
+ if (menuOpen) {
222
+ if (key.tab) {
223
+ if (matches.length === 1 && selected) {
224
+ acceptCommand(selected);
225
+ return;
226
+ }
227
+ setMenuIndex((i) => (key.shift ? (i - 1 + matches.length) % matches.length : (i + 1) % matches.length));
228
+ return;
229
+ }
230
+ if (pressedEnter) {
231
+ if (selected)
232
+ acceptCommand(selected);
233
+ return;
234
+ }
235
+ if (key.leftArrow || key.rightArrow)
236
+ return;
237
+ }
238
+ // Argument menu: Tab completes the highlight. Enter still submits the line
239
+ // so a fully typed command is never swallowed by the suggestion list.
240
+ if (argMenuOpen) {
241
+ if (key.tab) {
242
+ if (argSelected)
243
+ acceptArg(argSelected);
244
+ return;
245
+ }
246
+ }
247
+ const inkKey = key;
248
+ if (inkKey.home) {
249
+ applyEdit((v, c) => ({ value: v, caretIndex: moveToLineStart(v, c) }));
250
+ return;
251
+ }
252
+ if (inkKey.end) {
253
+ applyEdit((v, c) => ({ value: v, caretIndex: moveToLineEnd(v, c) }));
36
254
  return;
37
255
  }
38
- // Ctrl+J: insert newline
39
- if (key.ctrl && input === "j") {
40
- setValue((v) => v + "\n");
256
+ // Move one visual row first; only browse history when already at the edge.
257
+ if (key.upArrow) {
258
+ const { value: v, caretIndex: c } = editingRef.current;
259
+ const next = moveCaretVertical(v, c, -1, inputTextWidth(columns));
260
+ if (next !== c) {
261
+ applyEdit(() => ({ value: v, caretIndex: next }));
262
+ return;
263
+ }
264
+ browseTo(1);
265
+ return;
266
+ }
267
+ if (key.downArrow) {
268
+ const { value: v, caretIndex: c } = editingRef.current;
269
+ const next = moveCaretVertical(v, c, 1, inputTextWidth(columns));
270
+ if (next !== c) {
271
+ applyEdit(() => ({ value: v, caretIndex: next }));
272
+ return;
273
+ }
274
+ browseTo(-1);
275
+ return;
276
+ }
277
+ // Ctrl+J: insert newline. The terminal sends LF (0x0A) for Ctrl+J and CR
278
+ // (0x0D) for Enter; Ink parses both as "enter", but key.return is only
279
+ // true for the CR case, so a lone "\n" without key.return is Ctrl+J.
280
+ if (input === "\n" && !key.return) {
281
+ applyEdit((v, c) => insertAt(v, c, "\n"));
41
282
  return;
42
283
  }
43
284
  // Enter: submit or continue if ends with backslash
44
- if (key.return) {
45
- if (value.endsWith("\\")) {
46
- setValue((v) => v.slice(0, -1) + "\n");
285
+ if (pressedEnter) {
286
+ const v = editingRef.current.value;
287
+ if (v.endsWith("\\")) {
288
+ applyEdit((cv, cc) => insertAt(cv.slice(0, -1), cc, "\n"));
47
289
  return;
48
290
  }
49
- const text = value.trim();
50
- if (text) {
51
- onSubmit(text);
52
- setValue("");
291
+ const text = v.trim();
292
+ if (text)
293
+ submit(text);
294
+ applyEdit(() => ({ value: "", caretIndex: 0 }));
295
+ return;
296
+ }
297
+ // Left / right move the caret through the text
298
+ if (key.leftArrow) {
299
+ applyEdit((v, c) => ({ value: v, caretIndex: moveCaretHorizontal(v, c, -1) }));
300
+ return;
301
+ }
302
+ if (key.rightArrow) {
303
+ applyEdit((v, c) => ({ value: v, caretIndex: moveCaretHorizontal(v, c, 1) }));
304
+ return;
305
+ }
306
+ // Backspace (\b, Ctrl+H) — remove the code point before the caret. Most
307
+ // terminals send DEL (0x7F) for the Backspace key and CSI 3 ~ for Delete;
308
+ // Ink reports both of those as key.delete (not key.backspace), so they
309
+ // never reach this branch and are handled by the raw stdin listener,
310
+ // which can tell them apart.
311
+ if (key.backspace) {
312
+ applyEdit((v, c) => backspaceAt(v, c));
313
+ return;
314
+ }
315
+ if (key.escape || key.pageUp || key.pageDown)
316
+ return;
317
+ // Ctrl+R: history search by current input
318
+ if (key.ctrl && input === "r") {
319
+ const hit = searchHistory(historyRef.current, editingRef.current.value);
320
+ if (hit) {
321
+ const after = { value: hit, caretIndex: Array.from(hit).length };
322
+ editingRef.current = after;
323
+ setEditing(after);
53
324
  }
54
325
  return;
55
326
  }
56
- // Backspace / Delete
57
- if (key.backspace || key.delete) {
58
- setValue((v) => v.slice(0, -1));
327
+ // Readline keys
328
+ if (key.ctrl && input === "a") {
329
+ applyEdit((v, c) => ({ value: v, caretIndex: moveToLineStart(v, c) }));
59
330
  return;
60
331
  }
61
- // Escape, arrows, etc. ignore (handled elsewhere)
62
- if (key.escape || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
332
+ if (key.ctrl && input === "e") {
333
+ applyEdit((v, c) => ({ value: v, caretIndex: moveToLineEnd(v, c) }));
63
334
  return;
64
- if (key.pageUp || key.pageDown)
335
+ }
336
+ if (key.ctrl && input === "k") {
337
+ applyEdit((v, c) => deleteToLineEnd(v, c));
338
+ return;
339
+ }
340
+ if (key.ctrl && input === "u") {
341
+ applyEdit((v, c) => deleteToLineStart(v, c));
65
342
  return;
343
+ }
344
+ if (key.ctrl && input === "w") {
345
+ applyEdit((v, c) => deleteWordBefore(v, c));
346
+ return;
347
+ }
348
+ if (key.meta && input === "b") {
349
+ applyEdit((v, c) => ({ value: v, caretIndex: Math.max(0, c - 1) }));
350
+ return;
351
+ }
352
+ if (key.meta && input === "f") {
353
+ applyEdit((v, c) => ({ value: v, caretIndex: Math.min(Array.from(v).length, c + 1) }));
354
+ return;
355
+ }
66
356
  if (key.ctrl || key.meta)
67
357
  return;
358
+ // Kitty Shift+Enter sequence (or a fragment of it) — handled by the raw
359
+ // stdin listener, never as text
360
+ if (isKittySequenceFragment(input))
361
+ return;
362
+ if (/^\[<.*[Mm]$/.test(input) || input.startsWith("[<") || /^[\d;,]+[Mm]$/.test(input))
363
+ return;
364
+ // Bracketed paste: ESC[200~ ... ESC[200~ — Ink may deliver without ESC, treat as bulk insert
365
+ if (input.includes("\x1b[200~") || input.includes("[200~")) {
366
+ const cleaned = input.replace(/\x1b?\[200~|\x1b?\[201~/g, "");
367
+ if (cleaned)
368
+ applyEdit((v, c) => insertAt(v, c, cleaned));
369
+ return;
370
+ }
68
371
  // Tab → 2 spaces
69
372
  if (key.tab) {
70
- setValue((v) => v + " ");
373
+ applyEdit((v, c) => insertAt(v, c, " "));
71
374
  return;
72
375
  }
73
376
  // Normal character input (including CJK)
74
377
  if (input) {
75
- setValue((v) => v + input);
378
+ applyEdit((v, c) => insertAt(v, c, cleanInput));
76
379
  }
77
380
  }, { isActive: !disabled });
78
381
  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"] }) }))] }));
382
+ const shownPlaceholder = exitHint ? "再按一次 Ctrl+C 退出" : placeholder;
383
+ const logicalLines = value.split("\n");
384
+ const isMultiline = logicalLines.length > 1;
385
+ const borderColor = disabled ? theme.inputBorderDisabled : theme.inputBorder;
386
+ const textWidth = inputTextWidth(width);
387
+ // Visual rows of the value, plus the caret position inside them.
388
+ const rows = visualRows(value, textWidth);
389
+ const clampedCaret = Math.min(caretIndex, Array.from(value).length);
390
+ // Locate the caret's visual row and its character offset inside that row.
391
+ let caretRowIndex = rows.length - 1;
392
+ let caretCharOffset = Array.from(rows[caretRowIndex].text).length;
393
+ for (let i = 0; i < rows.length; i++) {
394
+ const row = rows[i];
395
+ const end = row.startIndex + Array.from(row.text).length;
396
+ if (clampedCaret >= row.startIndex && clampedCaret <= end) {
397
+ caretRowIndex = i;
398
+ caretCharOffset = clampedCaret - row.startIndex;
399
+ break;
400
+ }
401
+ }
402
+ // A caret at the end of a full row has no cell left — move it to the start
403
+ // of the next visual row (pushing an empty one when it is the last row).
404
+ let caretColumnOffset = displayWidth(Array.from(rows[caretRowIndex].text).slice(0, caretCharOffset).join(""));
405
+ if (caretColumnOffset >= textWidth) {
406
+ if (caretRowIndex === rows.length - 1) {
407
+ rows.push({
408
+ text: "",
409
+ startIndex: rows[caretRowIndex].startIndex + Array.from(rows[caretRowIndex].text).length,
410
+ });
411
+ }
412
+ caretRowIndex++;
413
+ caretCharOffset = 0;
414
+ caretColumnOffset = 0;
415
+ }
416
+ // The caret may sit on any content row; the only thing below it is the
417
+ // (single-row) multi-line hint — plus the slash menu when it floats below
418
+ // the input bar.
419
+ const hintRows = isMultiline ? 1 : 0;
420
+ const argShown = argMenuOpen ? Math.min(8, argOptions.length) : 0;
421
+ const menuRows = menuOpen ? matches.length + 5 : argMenuOpen ? argShown + 4 : 0;
422
+ // Input bar sits at the bottom of App's footer: its content area starts
423
+ // `terminalRows` rows up minus the bar's own height (menu + bottom border +
424
+ // hint above it in screen order, from the bottom of the terminal up).
425
+ const inputTopRow = terminalRows - rows.length - hintRows - menuRows;
426
+ // Latest geometry for the raw stdin listener, which subscribes only once
427
+ // (independent of re-renders) and needs these when a click arrives.
428
+ const rowsRef = useRef(rows);
429
+ rowsRef.current = rows;
430
+ const inputTopRowRef = useRef(inputTopRow);
431
+ inputTopRowRef.current = inputTopRow;
432
+ const hintRowsRef = useRef(hintRows);
433
+ hintRowsRef.current = hintRows;
434
+ // Listen for Kitty protocol Shift+Enter: ESC[13;2u, which may arrive split
435
+ // across stdin chunks — accumulate and reassemble here. Ink's useInput gets
436
+ // each raw chunk and would append fragments to the value, so isKittySequenceFragment
437
+ // below filters them out. Backspace (DEL 0x7F) and Delete (CSI 3 ~ and
438
+ // variants) are also picked out of the same raw stream, since Ink collapses
439
+ // them into one key.delete. SGR mouse click events arrive on the same raw
440
+ // stream; parse them and move the caret to the clicked cell. The listener
441
+ // subscribes once; every edit goes through applyEdit, which reads the
442
+ // synchronous editingRef, so fast key repeats never operate on stale text.
443
+ useEffect(() => {
444
+ if (!stdin || disabled)
445
+ return;
446
+ const handleData = (data) => {
447
+ const chunk = data.toString("utf-8");
448
+ // Kitty Shift+Enter accumulation
449
+ const { buffer: kittyBuf, shiftEnter } = accumulateKittyInput(kittyBuffer.current, chunk);
450
+ kittyBuffer.current = kittyBuf;
451
+ if (shiftEnter)
452
+ applyEdit((v, c) => insertAt(v, c, "\n"));
453
+ // Backspace (DEL 0x7F) and Delete (CSI 3 ~ / variants): Ink reports
454
+ // them all as key.delete, so they are distinguished and applied here.
455
+ // The buffer handles a sequence split across chunks.
456
+ const { buffer: delBuf, actions } = scanDeleteKeys(deleteBuffer.current, chunk);
457
+ deleteBuffer.current = delBuf;
458
+ if (actions.length > 0) {
459
+ for (const action of actions) {
460
+ applyEdit((v, c) => (action.kind === "backspace" ? backspaceAt(v, c) : deleteAt(v, c)));
461
+ }
462
+ }
463
+ const { buffer: homeBuf, actions: homeActions } = scanHomeEndKeys(homeEndBuffer.current, chunk);
464
+ homeEndBuffer.current = homeBuf;
465
+ if (homeActions.length > 0) {
466
+ for (const action of homeActions) {
467
+ applyEdit((v, c) => ({
468
+ value: v,
469
+ caretIndex: action.kind === "home" ? moveToLineStart(v, c) : moveToLineEnd(v, c),
470
+ }));
471
+ }
472
+ }
473
+ mouseBuffer.current += chunk;
474
+ let sgr;
475
+ while ((sgr = scanSgrMouse(mouseBuffer.current))) {
476
+ mouseBuffer.current = sgr.rest;
477
+ if (sgr.event.button === 0 && sgr.event.press) {
478
+ const contentRow = sgr.event.row - inputTopRowRef.current;
479
+ if (contentRow >= 0 && contentRow < rowsRef.current.length) {
480
+ const evCol = sgr.event.col;
481
+ applyEdit((v) => ({
482
+ value: v,
483
+ caretIndex: caretIndexFromClick(rowsRef.current, contentRow, evCol, TEXT_START_COLUMN),
484
+ }));
485
+ }
486
+ else {
487
+ const chrome = 3;
488
+ const header = menuOpenRef.current ? 1 : 0;
489
+ const optionIndex = sgr.event.row - (inputTopRowRef.current + rowsRef.current.length + hintRowsRef.current + chrome + header);
490
+ if (menuOpenRef.current) {
491
+ const cmd = matchesRef.current[optionIndex];
492
+ if (cmd)
493
+ acceptCommandRef.current(cmd);
494
+ }
495
+ else if (argMenuOpenRef.current) {
496
+ const shown = argOptionsRef.current.slice(0, 8);
497
+ const opt = shown[optionIndex];
498
+ if (opt)
499
+ acceptArgRef.current(opt);
500
+ }
501
+ }
502
+ }
503
+ }
504
+ if (mouseBuffer.current.length > 64) {
505
+ const esc = mouseBuffer.current.lastIndexOf("\x1b");
506
+ mouseBuffer.current = esc === -1 ? "" : mouseBuffer.current.slice(esc);
507
+ }
508
+ };
509
+ stdin.on("data", handleData);
510
+ return () => {
511
+ stdin.off("data", handleData);
512
+ };
513
+ }, [stdin, disabled, applyEdit]);
514
+ // Enable the SGR mouse protocol so clicks on the input bar can position the
515
+ // caret. Events arrive on raw stdin; parseSgrMouse picks them out of the
516
+ // same chunk stream as the Kitty sequence. Terminals without mouse support
517
+ // simply never send events — keyboard navigation still works.
518
+ useEffect(() => {
519
+ stdout.write(MOUSE_ENABLE);
520
+ return () => {
521
+ stdout.write(MOUSE_DISABLE);
522
+ };
523
+ }, [stdout]);
524
+ // Must publish during render (not in effect) — Ink writes the frame in
525
+ // resetAfterCommit before effects run. If delayed to useEffect, the
526
+ // hardware cursor (IME preedit anchor) lags by one frame and appears
527
+ // displaced while composing CJK.
528
+ {
529
+ const rowsBelowCaret = rows.length - 1 - caretRowIndex;
530
+ setCaretPosition({
531
+ rowsAbove: rowsBelowCaret + 1 /* bottom border */ + hintRows + menuRows,
532
+ column: TEXT_START_COLUMN + caretColumnOffset,
533
+ });
534
+ }
535
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: "100%", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: borderColor, paddingX: 1, width: "100%", children: [isEmpty && shownPlaceholder ? (_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: shownPlaceholder })] })) : (rows.map((row, i) => {
536
+ const chars = Array.from(row.text);
537
+ const caretHere = i === caretRowIndex;
538
+ const offset = caretHere ? caretCharOffset : chars.length;
539
+ const before = chars.slice(0, offset).join("");
540
+ const after = chars.slice(offset).join("");
541
+ 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));
542
+ })), 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, width: "100%", 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: "Tab \u9009\u62E9 \u00B7 Enter \u6267\u884C \u00B7 Esc \u5173\u95ED" }) })] })), argMenuOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.inputBorder, paddingX: 1, marginTop: 1, width: "100%", 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} 项 · ` : "", "Tab \u8865\u5168 \u00B7 Enter \u6267\u884C \u00B7 Esc \u5173\u95ED"] }) })] }))] }));
83
543
  }