min-agent 0.3.0 → 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 (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
@@ -2,7 +2,9 @@ import { tool, jsonSchema } from "ai";
2
2
  import { mkdir, writeFile } from "fs/promises";
3
3
  import { existsSync } from "fs";
4
4
  import path from "path";
5
- import { confirm, isAutoApprove } from "../confirm.js";
5
+ import { confirm, isEditAutoApprove } from "../confirm.js";
6
+ import { pathAccessError } from "../sandbox.js";
7
+ import { atomicWriteFile } from "./atomic-file.js";
6
8
  const MAX_WRITE_BYTES = 10 * 1024 * 1024;
7
9
  export const writeTool = tool({
8
10
  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.",
@@ -19,20 +21,35 @@ export const writeTool = tool({
19
21
  if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) {
20
22
  return `Error: content exceeds ${MAX_WRITE_BYTES} bytes. Write in smaller chunks or check the content size.`;
21
23
  }
24
+ const denied = pathAccessError(filePath, "write");
25
+ if (denied)
26
+ return `Error: ${denied}`;
22
27
  const resolved = path.resolve(process.cwd(), filePath);
23
- // Confirm overwriting existing files
24
- if (!append && !isAutoApprove() && existsSync(resolved)) {
25
- const approved = await confirm(`Overwrite existing file: ${filePath}`);
28
+ // Same confirmation semantics as edit: in ask mode every mutation —
29
+ // create, overwrite or append is confirmed; accept-edits skips them all.
30
+ if (!isEditAutoApprove()) {
31
+ const exists = existsSync(resolved);
32
+ const action = append
33
+ ? `Append to file: ${filePath}`
34
+ : exists
35
+ ? `Overwrite existing file: ${filePath}`
36
+ : `Create new file: ${filePath}`;
37
+ const approved = await confirm(action);
26
38
  if (!approved)
27
39
  return "Write rejected by user.";
28
40
  }
29
41
  try {
30
42
  await mkdir(path.dirname(resolved), { recursive: true });
31
- await writeFile(resolved, content, append ? { flag: "a", encoding: "utf-8" } : "utf-8");
43
+ if (append) {
44
+ await writeFile(resolved, content, { flag: "a", encoding: "utf-8" });
45
+ }
46
+ else {
47
+ await atomicWriteFile(resolved, content);
48
+ }
32
49
  return `Written ${Buffer.byteLength(content, "utf-8")} bytes to ${filePath}${append ? " (appended)" : ""}`;
33
50
  }
34
51
  catch (err) {
35
- return `Error writing file: ${err.message}`;
52
+ return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
36
53
  }
37
54
  },
38
55
  });
package/dist/tui/App.js CHANGED
@@ -8,9 +8,11 @@ import { ConfirmBar, confirmBarRows } from "./ConfirmBar.js";
8
8
  import { QuestionBar, questionBarRows } from "./QuestionBar.js";
9
9
  import { StatusBar } from "./StatusBar.js";
10
10
  import { ModelPicker, modelPickerRows } from "./ModelPicker.js";
11
+ import { SessionPicker, sessionPickerRows } from "./SessionPicker.js";
11
12
  import { setCaretPosition } from "./caret.js";
12
- import { computeMessageMaxHeight } from "./layout.js";
13
- export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onModelPick, onModelCancel, onExit, onCopyNotice }) {
13
+ import { computeMessageMaxHeight, frameRows, INPUT_BAR_ROWS } from "./layout.js";
14
+ import { isCtrlC } from "./input-history.js";
15
+ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onModelPick, onModelCancel, onSessionPick, onSessionCancel, onToggleSelectionMode, onExitSelectionMode, onExit, onCopyNotice, }) {
14
16
  // Ink recalculates its own layout on resize without re-rendering React, so
15
17
  // track terminal size ourselves to keep heights/widths in sync.
16
18
  const { stdout } = useStdout();
@@ -18,32 +20,79 @@ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onMod
18
20
  useEffect(() => {
19
21
  const onResize = () => setTerminal({ columns: stdout.columns || 80, rows: stdout.rows || 0 });
20
22
  stdout.on("resize", onResize);
21
- return () => { stdout.off("resize", onResize); };
23
+ return () => {
24
+ stdout.off("resize", onResize);
25
+ };
22
26
  }, [stdout]);
23
27
  useInput((input, key) => {
24
- if (key.ctrl && input === "c") {
25
- onExit();
28
+ if (isCtrlC(input, key)) {
29
+ if (initialState.confirmMessage) {
30
+ onConfirm(false);
31
+ return;
32
+ }
33
+ if (initialState.question) {
34
+ onQuestionAnswer(null);
35
+ return;
36
+ }
37
+ if (initialState.modelPicker) {
38
+ onModelCancel();
39
+ return;
40
+ }
41
+ if (initialState.sessionPicker) {
42
+ onSessionCancel();
43
+ return;
44
+ }
45
+ if (initialState.selectionMode) {
46
+ onExitSelectionMode();
47
+ return;
48
+ }
49
+ if (initialState.isRunning) {
50
+ onExit();
51
+ }
52
+ return;
53
+ }
54
+ if (key.ctrl && input === "b") {
55
+ if (initialState.confirmMessage ||
56
+ initialState.question ||
57
+ initialState.modelPicker ||
58
+ initialState.sessionPicker)
59
+ return;
60
+ if (initialState.isRunning)
61
+ return;
62
+ if (!initialState.selectionMode)
63
+ return;
64
+ onToggleSelectionMode();
26
65
  return;
27
66
  }
28
- if (key.escape && initialState.isRunning && !initialState.confirmMessage && !initialState.question && !initialState.modelPicker) {
67
+ if (key.escape &&
68
+ initialState.isRunning &&
69
+ !initialState.confirmMessage &&
70
+ !initialState.question &&
71
+ !initialState.modelPicker &&
72
+ !initialState.sessionPicker) {
29
73
  onExit();
30
74
  }
31
75
  });
32
- if (initialState.confirmMessage || initialState.question || initialState.modelPicker)
76
+ if (initialState.confirmMessage || initialState.question || initialState.modelPicker || initialState.sessionPicker)
33
77
  setCaretPosition(null);
34
78
  const columns = terminal.columns;
35
79
  const terminalRows = terminal.rows;
80
+ const layoutRows = frameRows(terminalRows);
36
81
  const confirmActive = Boolean(initialState.confirmMessage);
37
82
  const questionActive = Boolean(initialState.question);
38
83
  const modelPickerActive = Boolean(initialState.modelPicker);
84
+ const sessionPickerActive = Boolean(initialState.sessionPicker);
39
85
  const footerRows = confirmActive
40
- ? confirmBarRows(initialState.confirmMessage ?? "", columns)
86
+ ? confirmBarRows(initialState.confirmMessage ?? "", columns, layoutRows)
41
87
  : questionActive
42
- ? questionBarRows(initialState.question.prompt, initialState.question.options, columns)
88
+ ? questionBarRows(initialState.question.prompt, initialState.question.options, columns, layoutRows)
43
89
  : 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 }))] }));
90
+ ? modelPickerRows(layoutRows)
91
+ : sessionPickerActive
92
+ ? sessionPickerRows(layoutRows)
93
+ : INPUT_BAR_ROWS;
94
+ const spinnerVisible = initialState.isRunning && !confirmActive && !questionActive && !modelPickerActive && !sessionPickerActive;
95
+ const spinnerRows = spinnerVisible ? 1 : 0;
96
+ const messageMaxHeight = computeMessageMaxHeight(layoutRows, footerRows, spinnerRows);
97
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: layoutRows || undefined, overflow: "hidden", children: [!modelPickerActive && (_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", width: "100%", children: _jsx(MessageList, { messages: initialState.messages, maxHeight: messageMaxHeight, columns: columns, onCopyNotice: onCopyNotice, selectionMode: Boolean(initialState.selectionMode), onExitSelectionMode: onExitSelectionMode, interactive: !confirmActive && !questionActive }) })), spinnerVisible && (_jsx(Box, { paddingLeft: 1, flexShrink: 0, width: "100%", children: _jsx(Spinner, { label: initialState.spinnerText || "思考中..." }) })), _jsx(StatusBar, { state: initialState, columns: columns }), initialState.confirmMessage ? (_jsx(ConfirmBar, { message: initialState.confirmMessage, onConfirm: onConfirm, columns: columns, maxRows: footerRows })) : initialState.question ? (_jsx(QuestionBar, { prompt: initialState.question.prompt, options: initialState.question.options, onAnswer: onQuestionAnswer, columns: columns, maxRows: footerRows })) : initialState.modelPicker ? (_jsx(ModelPicker, { currentModel: initialState.model, onSelect: onModelPick, onCancel: onModelCancel })) : initialState.sessionPicker ? (_jsx(SessionPicker, { currentId: initialState.sessionId, onSelect: onSessionPick, onCancel: onSessionCancel })) : (_jsx(InputBar, { onSubmit: onSubmit, disabled: initialState.isRunning || Boolean(initialState.selectionMode), placeholder: initialState.isRunning ? "按 Esc 取消运行..." : "输入消息... (Ctrl+J 换行)", columns: columns, terminalRows: layoutRows, onSelectionMode: onToggleSelectionMode, onExit: onExit }))] }));
49
98
  }
@@ -1,34 +1,66 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
2
3
  import { Box, Text, useInput } from "ink";
3
4
  import { wrapByWidth } from "./text-width.js";
4
- import { innerTextWidth } from "./layout.js";
5
+ import { CONFIRM_CHROME_ROWS, footerMaxRows, innerTextWidth, sliceLineWindow } from "./layout.js";
5
6
  import { theme } from "./theme.js";
7
+ export function confirmMessageLines(message, columns) {
8
+ const textWidth = innerTextWidth(columns);
9
+ return String(message ?? "")
10
+ .split("\n")
11
+ .flatMap((line) => wrapByWidth(line, textWidth));
12
+ }
6
13
  /**
7
14
  * 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.
15
+ * Capped so a huge permission payload cannot push [y]/[N] off the screen.
10
16
  */
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
+ export function confirmBarRows(message, columns, frameRowCount = 0) {
18
+ const natural = confirmMessageLines(message, columns).length + CONFIRM_CHROME_ROWS;
19
+ return Math.min(natural, footerMaxRows(frameRowCount, CONFIRM_CHROME_ROWS));
17
20
  }
18
- export function ConfirmBar({ message, onConfirm }) {
21
+ export function ConfirmBar({ message, onConfirm, columns, maxRows }) {
22
+ const lines = useMemo(() => confirmMessageLines(message, columns), [message, columns]);
23
+ const bodyMax = Math.max(0, maxRows - CONFIRM_CHROME_ROWS);
24
+ const [top, setTop] = useState(0);
25
+ const window = useMemo(() => sliceLineWindow(lines, bodyMax, top), [lines, bodyMax, top]);
26
+ const scrollable = window.above > 0 || window.below > 0;
19
27
  useInput((input, key) => {
20
28
  if (key.escape) {
21
29
  onConfirm(false);
22
30
  return;
23
31
  }
24
32
  const hasEnter = key.return || /[\r\n]/.test(input);
25
- const stripped = input.replace(/[\r\n]+/g, "").trim().toLowerCase();
33
+ const stripped = input
34
+ .replace(/[\r\n]+/g, "")
35
+ .trim()
36
+ .toLowerCase();
26
37
  if (stripped === "y") {
27
38
  onConfirm(true);
39
+ return;
28
40
  }
29
- else if (stripped === "n" || (stripped === "" && hasEnter)) {
41
+ if (stripped === "n" || (stripped === "" && hasEnter)) {
30
42
  onConfirm(false);
43
+ return;
44
+ }
45
+ if (!scrollable)
46
+ return;
47
+ const view = Math.max(1, window.lines.length);
48
+ if (key.upArrow) {
49
+ setTop((t) => sliceLineWindow(lines, bodyMax, t - 1).start);
50
+ return;
51
+ }
52
+ if (key.downArrow) {
53
+ setTop((t) => sliceLineWindow(lines, bodyMax, t + 1).start);
54
+ return;
55
+ }
56
+ if (key.pageUp) {
57
+ setTop((t) => sliceLineWindow(lines, bodyMax, t - view).start);
58
+ return;
59
+ }
60
+ if (key.pageDown) {
61
+ setTop((t) => sliceLineWindow(lines, bodyMax, t + view).start);
62
+ return;
31
63
  }
32
64
  });
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]" })] })] }));
65
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: "100%", borderStyle: "round", borderColor: theme.confirmBorder, paddingX: 1, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { bold: true, color: theme.confirmBorder, wrap: "truncate-end", children: "\u26A0 \u8BF7\u6C42\u6267\u884C\u6743\u9650" }) }), bodyMax > 0 && (_jsxs(Box, { flexDirection: "column", height: bodyMax, overflow: "hidden", flexShrink: 0, width: "100%", children: [window.above > 0 && (_jsx(Box, { paddingLeft: 1, flexShrink: 0, children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4EE5\u4E0A ", window.above, " \u884C"] }) })), window.lines.map((line, i) => (_jsx(Box, { paddingLeft: 1, flexShrink: 0, children: _jsx(Text, { wrap: "truncate-end", children: line }) }, i))), window.below > 0 && (_jsx(Box, { paddingLeft: 1, flexShrink: 0, children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2193 \u8FD8\u6709 ", window.below, " \u884C"] }) }))] })), _jsxs(Box, { marginTop: 1, flexShrink: 0, height: 1, overflow: "hidden", width: "100%", children: [_jsxs(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]" })] })] }));
34
66
  }
@@ -3,10 +3,9 @@ import { useState, useEffect, useMemo, useRef, useCallback } from "react";
3
3
  import { Box, Text, useInput, useStdin, useStdout } from "ink";
4
4
  import { setCaretPosition } from "./caret.js";
5
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";
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";
10
9
  import { MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js";
11
10
  import { scanSgrMouse } from "./mouse.js";
12
11
  import { TEXT_START_COLUMN, inputTextWidth } from "./layout.js";
@@ -51,7 +50,7 @@ export function accumulateKittyInput(buffer, chunk) {
51
50
  * exactly; the hardware cursor is parked on that cell so IME composition
52
51
  * (Chinese, Japanese, Korean) shows up in the right place.
53
52
  */
54
- export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRows }) {
53
+ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRows, onSelectionMode, onExit, }) {
55
54
  const [editing, setEditing] = useState({ value: "", caretIndex: 0 });
56
55
  const { value, caretIndex } = editing;
57
56
  const [menuIndex, setMenuIndex] = useState(0);
@@ -61,15 +60,42 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
61
60
  const kittyBuffer = useRef("");
62
61
  const mouseBuffer = useRef("");
63
62
  const deleteBuffer = useRef("");
63
+ const homeEndBuffer = useRef("");
64
64
  const width = columns || 80;
65
- const [historyState, setHistoryState] = useState(() => createHistory());
65
+ const [historyState, setHistoryState] = useState(() => createHistory(loadInputHistory()));
66
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)
72
+ return;
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]);
67
92
  // The ref mirrors `editing` synchronously, so edits from separate stdin
68
93
  // listeners (Ink's useInput for text, the raw listener for delete keys)
69
94
  // always apply to the newest state, in arrival order, even when React has
70
95
  // not re-rendered yet (fast key repeats, text + delete in one chunk).
71
96
  const editingRef = useRef(editing);
72
97
  const applyEdit = useCallback((edit) => {
98
+ clearExitHint();
73
99
  const after = edit(editingRef.current.value, editingRef.current.caretIndex);
74
100
  editingRef.current = after;
75
101
  setEditing(after);
@@ -78,14 +104,15 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
78
104
  historyRef.current = nextHistory;
79
105
  setHistoryState(nextHistory);
80
106
  }
81
- }, []);
107
+ }, [clearExitHint]);
82
108
  const submit = (text) => {
83
109
  historyRef.current = pushInput(historyRef.current, text);
84
110
  setHistoryState(historyRef.current);
111
+ saveInputHistory(historyRef.current.entries);
85
112
  onSubmit(text);
86
113
  };
87
114
  const browseTo = (direction) => {
88
- const { text, state } = browseHistory(historyRef.current, direction);
115
+ const { text, state } = browseHistory(historyRef.current, direction, isKnownSlashCommandInput);
89
116
  historyRef.current = state;
90
117
  setHistoryState(state);
91
118
  if (text !== editingRef.current.value) {
@@ -95,7 +122,9 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
95
122
  }
96
123
  };
97
124
  // Release the caret when the input bar goes away (e.g. confirm prompt).
98
- useEffect(() => () => { setCaretPosition(null); }, []);
125
+ useEffect(() => () => {
126
+ setCaretPosition(null);
127
+ }, []);
99
128
  // Slash command menu — closes once a space follows the command name so the
100
129
  // argument menu can take over immediately.
101
130
  const matches = useMemo(() => filterSlashCommands(value), [value]);
@@ -107,7 +136,9 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
107
136
  const argMenuOpen = !menuOpen && !menuDismissed && argOptions.length > 0;
108
137
  const argIndex = argMenuOpen ? Math.min(menuIndex, argOptions.length - 1) : 0;
109
138
  const argSelected = argMenuOpen ? argOptions[argIndex] : undefined;
110
- useEffect(() => { setMenuIndex(0); }, [value]);
139
+ useEffect(() => {
140
+ setMenuIndex(0);
141
+ }, []);
111
142
  const acceptCommand = (cmd) => {
112
143
  if (cmd.argHint) {
113
144
  applyEdit(() => ({ value: `/${cmd.name} `, caretIndex: cmd.name.length + 2 }));
@@ -123,9 +154,47 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
123
154
  return { value: next, caretIndex: Array.from(next).length };
124
155
  });
125
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;
126
169
  useInput((input, key) => {
127
170
  if (disabled)
128
171
  return;
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
+ }
129
198
  // Fast typing or paste can deliver "text\r" as a single chunk — treat a
130
199
  // trailing Enter as Enter and strip it from the value.
131
200
  const cleanInput = input.replace(/[\r\n]+$/, "");
@@ -148,48 +217,60 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
148
217
  }
149
218
  // Any other key re-opens the menu
150
219
  setMenuDismissed(false);
151
- // Slash menu navigation
220
+ // Slash menu: Tab/Shift+Tab pick, Enter runs. ↑↓ always recall sent input.
152
221
  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);
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));
159
228
  return;
160
229
  }
161
- if (pressedEnter || key.tab) {
230
+ if (pressedEnter) {
162
231
  if (selected)
163
232
  acceptCommand(selected);
164
233
  return;
165
234
  }
166
- // Left/right are ignored while the menu is open
167
235
  if (key.leftArrow || key.rightArrow)
168
236
  return;
169
237
  }
170
- // Argument menu: ↑↓ pick, Tab completes. Enter still submits the line so a
171
- // fully typed command is never swallowed by the suggestion list.
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.
172
240
  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
241
  if (key.tab) {
182
242
  if (argSelected)
183
243
  acceptArg(argSelected);
184
244
  return;
185
245
  }
186
246
  }
187
- // Input history: ↑ older, ↓ newer (only when the slash menu is closed)
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) }));
254
+ return;
255
+ }
256
+ // Move one visual row first; only browse history when already at the edge.
188
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
+ }
189
264
  browseTo(1);
190
265
  return;
191
266
  }
192
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
+ }
193
274
  browseTo(-1);
194
275
  return;
195
276
  }
@@ -298,6 +379,7 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
298
379
  }
299
380
  }, { isActive: !disabled });
300
381
  const isEmpty = value === "";
382
+ const shownPlaceholder = exitHint ? "再按一次 Ctrl+C 退出" : placeholder;
301
383
  const logicalLines = value.split("\n");
302
384
  const isMultiline = logicalLines.length > 1;
303
385
  const borderColor = disabled ? theme.inputBorderDisabled : theme.inputBorder;
@@ -335,7 +417,8 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
335
417
  // (single-row) multi-line hint — plus the slash menu when it floats below
336
418
  // the input bar.
337
419
  const hintRows = isMultiline ? 1 : 0;
338
- const menuRows = menuOpen ? matches.length + 5 /* margin + borders + header + footer */ : 0;
420
+ const argShown = argMenuOpen ? Math.min(8, argOptions.length) : 0;
421
+ const menuRows = menuOpen ? matches.length + 5 : argMenuOpen ? argShown + 4 : 0;
339
422
  // Input bar sits at the bottom of App's footer: its content area starts
340
423
  // `terminalRows` rows up minus the bar's own height (menu + bottom border +
341
424
  // hint above it in screen order, from the bottom of the terminal up).
@@ -346,6 +429,8 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
346
429
  rowsRef.current = rows;
347
430
  const inputTopRowRef = useRef(inputTopRow);
348
431
  inputTopRowRef.current = inputTopRow;
432
+ const hintRowsRef = useRef(hintRows);
433
+ hintRowsRef.current = hintRows;
349
434
  // Listen for Kitty protocol Shift+Enter: ESC[13;2u, which may arrive split
350
435
  // across stdin chunks — accumulate and reassemble here. Ink's useInput gets
351
436
  // each raw chunk and would append fragments to the value, so isKittySequenceFragment
@@ -375,6 +460,16 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
375
460
  applyEdit((v, c) => (action.kind === "backspace" ? backspaceAt(v, c) : deleteAt(v, c)));
376
461
  }
377
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
+ }
378
473
  mouseBuffer.current += chunk;
379
474
  let sgr;
380
475
  while ((sgr = scanSgrMouse(mouseBuffer.current))) {
@@ -388,6 +483,22 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
388
483
  caretIndex: caretIndexFromClick(rowsRef.current, contentRow, evCol, TEXT_START_COLUMN),
389
484
  }));
390
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
+ }
391
502
  }
392
503
  }
393
504
  if (mouseBuffer.current.length > 64) {
@@ -396,7 +507,9 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
396
507
  }
397
508
  };
398
509
  stdin.on("data", handleData);
399
- return () => { stdin.off("data", handleData); };
510
+ return () => {
511
+ stdin.off("data", handleData);
512
+ };
400
513
  }, [stdin, disabled, applyEdit]);
401
514
  // Enable the SGR mouse protocol so clicks on the input bar can position the
402
515
  // caret. Events arrive on raw stdin; parseSgrMouse picks them out of the
@@ -404,7 +517,9 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
404
517
  // simply never send events — keyboard navigation still works.
405
518
  useEffect(() => {
406
519
  stdout.write(MOUSE_ENABLE);
407
- return () => { stdout.write(MOUSE_DISABLE); };
520
+ return () => {
521
+ stdout.write(MOUSE_DISABLE);
522
+ };
408
523
  }, [stdout]);
409
524
  // Must publish during render (not in effect) — Ink writes the frame in
410
525
  // resetAfterCommit before effects run. If delayed to useEffect, the
@@ -417,12 +532,12 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
417
532
  column: TEXT_START_COLUMN + caretColumnOffset,
418
533
  });
419
534
  }
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) => {
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) => {
421
536
  const chars = Array.from(row.text);
422
537
  const caretHere = i === caretRowIndex;
423
538
  const offset = caretHere ? caretCharOffset : chars.length;
424
539
  const before = chars.slice(0, offset).join("");
425
540
  const after = chars.slice(offset).join("");
426
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));
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"] }) })] }))] }));
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"] }) })] }))] }));
428
543
  }