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
@@ -41,7 +41,9 @@ export function ModelPicker({ currentModel, onSelect, onCancel }) {
41
41
  else {
42
42
  setModels([]);
43
43
  }
44
- return () => { cancelled = true; };
44
+ return () => {
45
+ cancelled = true;
46
+ };
45
47
  }, []);
46
48
  const terminalRows = stdout.rows || 24;
47
49
  const maxListRows = Math.max(4, terminalRows - 11); // chrome + status bar + safety
@@ -52,7 +54,9 @@ export function ModelPicker({ currentModel, onSelect, onCancel }) {
52
54
  const shown = filtered.slice(windowStart, windowStart + maxListRows);
53
55
  const extraAbove = windowStart;
54
56
  const extraBelow = filtered.length - (windowStart + shown.length);
55
- useEffect(() => { setIndex(0); }, [q]);
57
+ useEffect(() => {
58
+ setIndex(0);
59
+ }, []);
56
60
  useInput((input, key) => {
57
61
  const cleanInput = input.replace(/[\r\n]+$/, "");
58
62
  const isEnter = key.return || /[\r\n]+$/.test(input);
@@ -104,5 +108,6 @@ export function ModelPicker({ currentModel, onSelect, onCancel }) {
104
108
  const empty = models !== null && filtered.length === 0 && q === "";
105
109
  // Debounce filter: avoid re-filtering on every keystroke burst
106
110
  const debouncedQ = q;
107
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.pickerBorder, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.pickerBorder, children: "\u9009\u62E9\u6A21\u578B" }), _jsx(Text, { color: theme.muted, children: currentModel ? ` (当前: ${currentModel})` : "" })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "\u7B5B\u9009: " }), _jsx(Text, { color: query ? theme.accent : theme.muted, children: query || "输入字符过滤,↑↓/PgUpDn 导航" })] }), loading && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u6B63\u5728\u83B7\u53D6\u6A21\u578B\u5217\u8868..." }) })), empty && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u65E0\u6CD5\u83B7\u53D6\u6A21\u578B\u5217\u8868\uFF08\u53EF\u8F93\u5165\u6A21\u578B\u540D\u540E\u6309 Enter\uFF09" }) })), models !== null && extraAbove > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0A\u65B9\u8FD8\u6709 ", extraAbove, " \u4E2A\u6A21\u578B"] }) })), models !== null && shown.map((m, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === shownIndex - windowStart ? theme.accent : theme.muted, children: i === shownIndex - windowStart ? "❯ " : " " }), _jsx(Text, { bold: i === shownIndex - windowStart, color: i === shownIndex - windowStart ? theme.accent : undefined, children: m }), m === currentModel && _jsx(Text, { color: theme.success, children: " \u2190 \u5F53\u524D" })] }, m))), extraBelow > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0B\u65B9\u8FD8\u6709 ", extraBelow, " \u4E2A\u6A21\u578B\uFF08\u8F93\u5165\u5B57\u7B26\u8FC7\u6EE4\uFF09"] }) })), models !== null && filtered.length === 0 && debouncedQ !== "" && (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "\u276F " }), _jsxs(Text, { color: theme.accent, bold: true, children: ["\u4F7F\u7528\u81EA\u5B9A\u4E49\u6A21\u578B: ", debouncedQ] })] })), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193/PgUpDn \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u53D6\u6D88" }) })] }));
111
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: "100%", borderStyle: "round", borderColor: theme.pickerBorder, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.pickerBorder, children: "\u9009\u62E9\u6A21\u578B" }), _jsx(Text, { color: theme.muted, children: currentModel ? ` (当前: ${currentModel})` : "" })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "\u7B5B\u9009: " }), _jsx(Text, { color: query ? theme.accent : theme.muted, children: query || "输入字符过滤,↑↓/PgUpDn 导航" })] }), loading && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u6B63\u5728\u83B7\u53D6\u6A21\u578B\u5217\u8868..." }) })), empty && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u65E0\u6CD5\u83B7\u53D6\u6A21\u578B\u5217\u8868\uFF08\u53EF\u8F93\u5165\u6A21\u578B\u540D\u540E\u6309 Enter\uFF09" }) })), models !== null && extraAbove > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0A\u65B9\u8FD8\u6709 ", extraAbove, " \u4E2A\u6A21\u578B"] }) })), models !== null &&
112
+ shown.map((m, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === shownIndex - windowStart ? theme.accent : theme.muted, children: i === shownIndex - windowStart ? "❯ " : " " }), _jsx(Text, { bold: i === shownIndex - windowStart, color: i === shownIndex - windowStart ? theme.accent : undefined, children: m }), m === currentModel && _jsx(Text, { color: theme.success, children: " \u2190 \u5F53\u524D" })] }, m))), extraBelow > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0B\u65B9\u8FD8\u6709 ", extraBelow, " \u4E2A\u6A21\u578B\uFF08\u8F93\u5165\u5B57\u7B26\u8FC7\u6EE4\uFF09"] }) })), models !== null && filtered.length === 0 && debouncedQ !== "" && (_jsxs(Box, { children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["\u276F", " "] }), _jsxs(Text, { color: theme.accent, bold: true, children: ["\u4F7F\u7528\u81EA\u5B9A\u4E49\u6A21\u578B: ", debouncedQ] })] })), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193/PgUpDn \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u53D6\u6D88" }) })] }));
108
113
  }
@@ -1,45 +1,71 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useState } from "react";
2
+ import { useMemo, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { wrapByWidth } from "./text-width.js";
5
- import { innerTextWidth } from "./layout.js";
5
+ import { footerMaxRows, innerTextWidth, QUESTION_CHROME_ROWS, sliceLineWindow } from "./layout.js";
6
6
  import { theme } from "./theme.js";
7
+ import { normalizeQuestionOptions, normalizeQuestionPrompt } from "../question-format.js";
8
+ export function questionBodyLines(prompt, options, columns) {
9
+ const textWidth = innerTextWidth(columns);
10
+ const promptLines = `❓ ${normalizeQuestionPrompt(prompt)}`
11
+ .split("\n")
12
+ .flatMap((line) => wrapByWidth(line, textWidth));
13
+ const optionLines = normalizeQuestionOptions(options).flatMap((o, i) => ` ${i + 1}. ${o}`.split("\n").flatMap((l) => wrapByWidth(l, textWidth)));
14
+ return { lines: [...promptLines, ...optionLines], promptLineCount: promptLines.length };
15
+ }
7
16
  /**
8
17
  * Terminal rows the question bar occupies, mirroring the JSX layout below.
9
- * Needed by App to bound the message area so the frame never exceeds the
10
- * terminal height while the prompt is up.
18
+ * Capped so a huge prompt cannot push the input row off the screen.
11
19
  */
12
- export function questionBarRows(prompt, options, columns) {
13
- const textWidth = innerTextWidth(columns);
14
- let rows = 0;
15
- for (const line of prompt.split("\n"))
16
- rows += wrapByWidth(line, textWidth).length;
17
- if (options)
18
- for (const o of options)
19
- for (const l of o.split("\n"))
20
- rows += wrapByWidth(l, textWidth).length;
21
- return rows + 7; /* borders2 + title1 + marginTop1 + hint1 + inputRow1 + margin1 */
20
+ export function questionBarRows(prompt, options, columns, frameRowCount = 0) {
21
+ const natural = questionBodyLines(prompt, options, columns).lines.length + QUESTION_CHROME_ROWS;
22
+ return Math.min(natural, footerMaxRows(frameRowCount, QUESTION_CHROME_ROWS));
22
23
  }
23
24
  /** Free-form question prompt: type an answer (or a number to pick an option), Enter to submit, Esc to cancel. */
24
- export function QuestionBar({ prompt, options, onAnswer }) {
25
+ export function QuestionBar({ prompt, options, onAnswer, columns, maxRows }) {
25
26
  const [value, setValue] = useState("");
26
27
  const [caret, setCaret] = useState(0);
28
+ const [top, setTop] = useState(0);
29
+ const { lines, promptLineCount } = useMemo(() => questionBodyLines(prompt, options, columns), [prompt, options, columns]);
30
+ const bodyMax = Math.max(0, maxRows - QUESTION_CHROME_ROWS);
31
+ const window = useMemo(() => sliceLineWindow(lines, bodyMax, top), [lines, bodyMax, top]);
32
+ const scrollable = window.above > 0 || window.below > 0;
33
+ const choices = normalizeQuestionOptions(options);
27
34
  useInput((input, key) => {
28
35
  if (key.escape) {
29
36
  onAnswer(null);
30
37
  return;
31
38
  }
32
39
  if (key.return) {
33
- if (/^\d+$/.test(value.trim()) && options?.length) {
40
+ if (/^\d+$/.test(value.trim()) && choices.length > 0) {
34
41
  const n = parseInt(value.trim(), 10);
35
- if (n >= 1 && n <= options.length) {
36
- onAnswer(options[n - 1]);
42
+ if (n >= 1 && n <= choices.length) {
43
+ onAnswer(choices[n - 1]);
37
44
  return;
38
45
  }
39
46
  }
40
47
  onAnswer(value.trim());
41
48
  return;
42
49
  }
50
+ if (scrollable) {
51
+ const view = Math.max(1, window.lines.length);
52
+ if (key.upArrow) {
53
+ setTop((t) => sliceLineWindow(lines, bodyMax, t - 1).start);
54
+ return;
55
+ }
56
+ if (key.downArrow) {
57
+ setTop((t) => sliceLineWindow(lines, bodyMax, t + 1).start);
58
+ return;
59
+ }
60
+ if (key.pageUp) {
61
+ setTop((t) => sliceLineWindow(lines, bodyMax, t - view).start);
62
+ return;
63
+ }
64
+ if (key.pageDown) {
65
+ setTop((t) => sliceLineWindow(lines, bodyMax, t + view).start);
66
+ return;
67
+ }
68
+ }
43
69
  if (key.leftArrow) {
44
70
  setCaret((c) => Math.max(0, c - 1));
45
71
  return;
@@ -100,5 +126,11 @@ export function QuestionBar({ prompt, options, onAnswer }) {
100
126
  setCaret((c) => c + ins.length);
101
127
  }
102
128
  });
103
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.questionBorder, paddingX: 1, children: [_jsx(Box, { children: _jsxs(Text, { bold: true, color: theme.questionBorder, children: ["\u2753 ", prompt] }) }), options && options.length > 0 && (_jsx(Box, { paddingLeft: 1, flexDirection: "column", children: options.map((opt, i) => (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, children: [" ", i + 1, ". ", opt] }) }, i))) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.questionBorder, children: "\u2192 " }), _jsx(Text, { children: Array.from(value).slice(0, caret).join("") }), _jsx(Text, { color: theme.muted, inverse: true, children: " " }), _jsx(Text, { children: Array.from(value).slice(caret).join("") })] }), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "Enter \u63D0\u4EA4 \u00B7 Esc \u53D6\u6D88 \u00B7 \u2190\u2192 \u5149\u6807 \u00B7 \u6570\u5B57\u9009\u9009\u9879" }) })] }));
129
+ const hint = scrollable
130
+ ? "Enter 提交 · Esc 取消 · ↑↓ 滚动 · 数字选选项"
131
+ : "Enter 提交 · Esc 取消 · ←→ 光标 · 数字选选项";
132
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: "100%", borderStyle: "round", borderColor: theme.questionBorder, paddingX: 1, children: [bodyMax > 0 && (_jsxs(Box, { flexDirection: "column", height: bodyMax, overflow: "hidden", flexShrink: 0, width: "100%", children: [window.above > 0 && (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4EE5\u4E0A ", window.above, " \u884C"] }) })), window.lines.map((line, i) => {
133
+ const isPrompt = window.start + i < promptLineCount;
134
+ return (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { bold: isPrompt, color: isPrompt ? theme.questionBorder : theme.muted, wrap: "truncate-end", children: line }) }, i));
135
+ }), window.below > 0 && (_jsx(Box, { 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.questionBorder, children: ["\u2192", " "] }), _jsx(Text, { children: Array.from(value).slice(0, caret).join("") }), _jsx(Text, { color: theme.muted, inverse: true, children: " " }), _jsx(Text, { children: Array.from(value).slice(caret).join("") })] }), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: hint }) })] }));
104
136
  }
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useInput, useStdout } from "ink";
4
+ import { listSessions } from "../sessions.js";
5
+ import { theme } from "./theme.js";
6
+ export function sessionPickerRows(terminalRows) {
7
+ const maxListRows = Math.max(4, terminalRows - 11);
8
+ return Math.min(6 + maxListRows + 3, Math.max(6, terminalRows - 2));
9
+ }
10
+ function label(s) {
11
+ const date = s.updated.split("T")[0] ?? "";
12
+ return `${s.id} ${date} ${s.title} (${s.messageCount})`;
13
+ }
14
+ export function SessionPicker({ currentId, onSelect, onCancel }) {
15
+ const sessions = listSessions();
16
+ const [query, setQuery] = useState("");
17
+ const [index, setIndex] = useState(0);
18
+ const { stdout } = useStdout();
19
+ const terminalRows = stdout.rows || 24;
20
+ const maxListRows = Math.max(4, terminalRows - 11);
21
+ const q = query.trim().toLowerCase();
22
+ const filtered = sessions.filter((s) => s.id.toLowerCase().includes(q) || s.title.toLowerCase().includes(q));
23
+ const shownIndex = Math.min(index, Math.max(0, filtered.length - 1));
24
+ const windowStart = Math.min(shownIndex, Math.max(0, filtered.length - maxListRows));
25
+ const shown = filtered.slice(windowStart, windowStart + maxListRows);
26
+ const extraAbove = windowStart;
27
+ const extraBelow = filtered.length - (windowStart + shown.length);
28
+ useEffect(() => {
29
+ setIndex(0);
30
+ }, []);
31
+ useInput((input, key) => {
32
+ const cleanInput = input.replace(/[\r\n]+$/, "");
33
+ const isEnter = key.return || /[\r\n]+$/.test(input);
34
+ if (key.escape) {
35
+ onCancel();
36
+ return;
37
+ }
38
+ if (key.pageDown) {
39
+ if (filtered.length > 0)
40
+ setIndex((i) => Math.min(filtered.length - 1, i + maxListRows));
41
+ return;
42
+ }
43
+ if (key.pageUp) {
44
+ if (filtered.length > 0)
45
+ setIndex((i) => Math.max(0, i - maxListRows));
46
+ return;
47
+ }
48
+ if (filtered.length > 0) {
49
+ if (key.downArrow) {
50
+ setIndex((i) => (i + 1) % filtered.length);
51
+ return;
52
+ }
53
+ if (key.upArrow) {
54
+ setIndex((i) => (i - 1 + filtered.length) % filtered.length);
55
+ return;
56
+ }
57
+ }
58
+ if (isEnter) {
59
+ if (filtered.length > 0)
60
+ onSelect(filtered[shownIndex].id);
61
+ return;
62
+ }
63
+ if (key.backspace || key.delete) {
64
+ setQuery((v) => Array.from(v).slice(0, -1).join(""));
65
+ return;
66
+ }
67
+ if (key.ctrl && input === "u") {
68
+ setQuery("");
69
+ return;
70
+ }
71
+ if (key.ctrl || key.meta || key.tab)
72
+ return;
73
+ if (cleanInput)
74
+ setQuery((v) => v + cleanInput);
75
+ });
76
+ const empty = filtered.length === 0 && q === "";
77
+ const noMatch = filtered.length === 0 && q !== "";
78
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: "100%", borderStyle: "round", borderColor: theme.pickerBorder, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.pickerBorder, children: "\u9009\u62E9\u4F1A\u8BDD" }), _jsx(Text, { color: theme.muted, children: currentId ? ` (当前: ${currentId})` : "" })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "\u7B5B\u9009: " }), _jsx(Text, { color: query ? theme.accent : theme.muted, children: query || "输入字符过滤标题或 id" })] }), empty && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u65E0\u5DF2\u4FDD\u5B58\u7684\u4F1A\u8BDD" }) })), noMatch && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u6CA1\u6709\u5339\u914D\u7684\u4F1A\u8BDD" }) })), extraAbove > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0A\u65B9\u8FD8\u6709 ", extraAbove, " \u4E2A\u4F1A\u8BDD"] }) })), shown.map((s, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === shownIndex - windowStart ? theme.accent : theme.muted, children: i === shownIndex - windowStart ? "❯ " : " " }), _jsx(Text, { bold: i === shownIndex - windowStart, color: i === shownIndex - windowStart ? theme.accent : undefined, wrap: "truncate-end", children: label(s) }), s.id === currentId && _jsx(Text, { color: theme.success, children: " \u2190" })] }, s.id))), extraBelow > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0B\u65B9\u8FD8\u6709 ", extraBelow, " \u4E2A\u4F1A\u8BDD"] }) })), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193/PgUpDn \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u53D6\u6D88" }) })] }));
79
+ }
@@ -1,24 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { formatTokenStatusLabel } from "../token-display.js";
3
4
  import { displayWidth } from "./text-width.js";
4
5
  export function StatusBar({ state, columns = 80 }) {
5
6
  const model = state.model || "unknown";
6
- const tokens = state.tokenInfo;
7
- const fmtK = (n) => `${(n / 1000).toFixed(1)}k`;
8
- const ctxPct = tokens && tokens.contextWindow > 0 ? Math.round((tokens.input / tokens.contextWindow) * 100) : null;
9
- const tokensLabel = tokens && (tokens.input > 0 || tokens.output > 0)
10
- ? [
11
- tokens.input > 0 ? `${fmtK(tokens.input)} in` : null,
12
- tokens.output > 0 ? `${fmtK(tokens.output)} out` : null,
13
- ctxPct != null ? `${ctxPct}%` : null,
14
- ].filter(Boolean).join(" · ")
15
- : "";
7
+ const tokensLabel = state.tokenInfo ? formatTokenStatusLabel(state.tokenInfo) : "";
16
8
  // Transient copy feedback rides on the separator row itself (right end) so
17
9
  // it never adds a row and the layout does not jump while it is visible.
18
10
  const sepWidth = Math.max(8, columns - 2);
19
- const notice = state.copyNotice ? `✓ ${state.copyNotice}` : null;
11
+ const notice = state.copyNotice
12
+ ? `✓ ${state.copyNotice}`
13
+ : state.selectionMode
14
+ ? "选择模式 ↑↓←→扩选 Enter复制 Esc退出"
15
+ : null;
20
16
  const noticeWidth = notice ? displayWidth(notice) : 0;
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: notice
22
- ? `${"─".repeat(Math.max(0, sepWidth - noticeWidth - 2))} ${notice}`
23
- : "─".repeat(sepWidth) }), _jsxs(Box, { children: [_jsx(Text, { children: " \uD83E\uDD16 min-agent" }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [" (", model, ")"] }), tokensLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: tokensLabel })] }))] })] }));
17
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: "100%", children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: notice ? `${"─".repeat(Math.max(0, sepWidth - noticeWidth - 2))} ${notice}` : "─".repeat(sepWidth) }), _jsxs(Box, { children: [_jsx(Text, { children: " \uD83E\uDD16 min-agent" }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [" ", "(", model, ")"] }), tokensLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: tokensLabel })] }))] })] }));
24
18
  }
@@ -18,10 +18,44 @@ export function createAgentRunner(deps) {
18
18
  abortController = new AbortController();
19
19
  deps.tui.setRunning(true, "思考中...");
20
20
  const { summarizeToolCall, summarizeToolResult, toolResultText } = await import("../tool-display.js");
21
- const { getContextWindow } = await import("../context-window.js");
21
+ const { getContextWindowInfo } = await import("../context-window.js");
22
22
  const { getModelPrice, estimateCost, formatCost } = await import("../pricing.js");
23
- const { runOnce, runOnceWithSystem } = await import("../agent.js");
23
+ const { runOnceWithSystem, emptyResponseMessage, WRAP_UP_MESSAGE } = await import("../agent.js");
24
24
  const { clearStack, pushTurn } = await import("./undo-stack.js");
25
+ const { setSubAgentListener } = await import("../tools/task.js");
26
+ const { LOOP_HALT_MESSAGE } = await import("../doom-loop.js");
27
+ const { createSaveThrottle } = await import("../save-throttle.js");
28
+ const { snapshotTokenInfo } = await import("./token-info.js");
29
+ // Persist while the run is in flight so a crash/kill does not lose the turn.
30
+ const saveThrottle = createSaveThrottle(() => deps.persist?.());
31
+ const billedInAtStart = deps.tracker.totalInputTokens;
32
+ const billedOutAtStart = deps.tracker.totalOutputTokens;
33
+ let tokenTimer = null;
34
+ let tokenPaintSeq = 0;
35
+ const applyTokenInfo = async () => {
36
+ const seq = ++tokenPaintSeq;
37
+ const info = await snapshotTokenInfo(deps.tracker, deps.getModel(), deps.messages);
38
+ if (seq !== tokenPaintSeq)
39
+ return;
40
+ deps.tui.setTokenInfo(info);
41
+ };
42
+ const scheduleTokenInfo = () => {
43
+ if (tokenTimer != null)
44
+ return;
45
+ tokenTimer = setTimeout(() => {
46
+ tokenTimer = null;
47
+ void applyTokenInfo();
48
+ }, 50);
49
+ };
50
+ const flushTokenInfo = async () => {
51
+ if (tokenTimer != null) {
52
+ clearTimeout(tokenTimer);
53
+ tokenTimer = null;
54
+ }
55
+ await applyTokenInfo();
56
+ };
57
+ const unsubscribeTokens = deps.tracker.subscribe(scheduleTokenInfo);
58
+ void applyTokenInfo();
25
59
  const callbacks = {
26
60
  onAssistantDisplayDelta: (delta) => deps.tui.appendToLast(delta),
27
61
  onThinkingDelta: (delta) => {
@@ -46,45 +80,123 @@ export function createAgentRunner(deps) {
46
80
  onStreamError: (msg) => {
47
81
  deps.tui.addMessage({ id: `err-${Date.now()}`, role: "system", content: `错误: ${msg}`, timestamp: Date.now() });
48
82
  },
49
- onCompaction: () => {
83
+ onCompaction: (line) => {
50
84
  compactedDuringRun = true;
51
85
  deps.setUndoStack(clearStack(deps.getUndoStack()));
86
+ if (line === "compacting_start") {
87
+ deps.tui.addMessage({
88
+ id: `compact-${Date.now()}`,
89
+ role: "system",
90
+ content: "正在整理过长的对话记录…",
91
+ timestamp: Date.now(),
92
+ });
93
+ }
94
+ else if (line.startsWith("compacted_ok")) {
95
+ deps.tui.addMessage({
96
+ id: `compact-ok-${Date.now()}`,
97
+ role: "system",
98
+ content: "✓ 已整理对话记录,继续执行",
99
+ timestamp: Date.now(),
100
+ });
101
+ }
102
+ },
103
+ onRetryNotice: (info) => {
104
+ deps.tui.addMessage({
105
+ id: `retry-${Date.now()}`,
106
+ role: "system",
107
+ content: info.kind === "wrap_up"
108
+ ? "⟳ 本轮预算用尽,正在收尾…"
109
+ : `⟳ 模型返回空响应,重试 ${info.attempt}/${info.maxAttempts}…`,
110
+ timestamp: Date.now(),
111
+ });
52
112
  },
53
113
  onRunFinish: async (info) => {
54
114
  abortController = null;
55
115
  deps.tui.setRunning(false);
116
+ await flushTokenInfo();
56
117
  if (info.aborted)
57
118
  return;
58
119
  if (info.budgetExceeded)
59
- deps.tui.addMessage({ id: `budget-${Date.now()}`, role: "system", content: "⚠ 已达到预算上限。/budget 查看或调高", timestamp: Date.now() });
60
- if (info.usage) {
61
- const input = info.usage.inputTokens ?? 0;
62
- const output = info.usage.outputTokens ?? 0;
63
- const total = input + output;
64
- const ctxWindow = await getContextWindow(deps.getModel());
65
- const pct = ctxWindow > 0 ? Math.round((input / ctxWindow) * 100) : 0;
66
- deps.tui.setTokenInfo(input, output, ctxWindow);
120
+ deps.tui.addMessage({
121
+ id: `budget-${Date.now()}`,
122
+ role: "system",
123
+ content: "⚠ 已达到预算上限。/budget 查看或调高",
124
+ timestamp: Date.now(),
125
+ });
126
+ const runInput = Math.max(0, deps.tracker.totalInputTokens - billedInAtStart);
127
+ const runOutput = Math.max(0, deps.tracker.totalOutputTokens - billedOutAtStart);
128
+ const occupied = deps.tracker.lastInputTokens;
129
+ if (runInput > 0 || runOutput > 0 || occupied > 0) {
130
+ const ctx = await getContextWindowInfo(deps.getModel());
131
+ const pct = ctx.tokens > 0 && occupied > 0 ? Math.round((occupied / ctx.tokens) * 100) : null;
67
132
  const price = await getModelPrice(deps.getModel() ?? "");
68
- const cost = estimateCost({ inputTokens: input, outputTokens: output }, price);
69
- deps.tui.addMessage({ id: `done-${Date.now()}`, role: "system", content: `Done in ${info.stepCount} step(s) | Tokens: ${input} in / ${output} out / ${total} total | Context: ${pct}%${cost != null ? ` | 约 ${formatCost(cost)}` : ""}`, timestamp: Date.now() });
133
+ const cost = estimateCost({ inputTokens: runInput, outputTokens: runOutput }, price);
134
+ const parts = [
135
+ `Done in ${info.stepCount} step(s)`,
136
+ `Tokens: ${runInput} in / ${runOutput} out / ${runInput + runOutput} total`,
137
+ ...(pct != null ? [`Context: ${pct}%`] : []),
138
+ ...(cost != null ? [`约 ${formatCost(cost)}`] : []),
139
+ ];
140
+ deps.tui.addMessage({
141
+ id: `done-${Date.now()}`,
142
+ role: "system",
143
+ content: parts.join(" | "),
144
+ timestamp: Date.now(),
145
+ });
70
146
  }
71
147
  if (info.maxStepsReached)
72
- deps.tui.addMessage({ id: `maxsteps-${Date.now()}`, role: "system", content: "⚠ 达到本轮 30 步上限,发送消息继续", timestamp: Date.now() });
148
+ deps.tui.addMessage({
149
+ id: `maxsteps-${Date.now()}`,
150
+ role: "system",
151
+ content: "⚠ 已达到自动续跑上限,发送消息继续",
152
+ timestamp: Date.now(),
153
+ });
154
+ if (info.stopped)
155
+ deps.tui.addMessage({
156
+ id: `stopped-${Date.now()}`,
157
+ role: "system",
158
+ content: `⚠ ${LOOP_HALT_MESSAGE}`,
159
+ timestamp: Date.now(),
160
+ });
161
+ else if (info.wrapUp)
162
+ deps.tui.addMessage({
163
+ id: `wrapup-${Date.now()}`,
164
+ role: "system",
165
+ content: `⚠ ${WRAP_UP_MESSAGE}`,
166
+ timestamp: Date.now(),
167
+ });
168
+ else if (info.emptyResponse)
169
+ deps.tui.addMessage({
170
+ id: `empty-${Date.now()}`,
171
+ role: "system",
172
+ content: `⚠ ${emptyResponseMessage(info.emptyAttempts ?? 0)}`,
173
+ timestamp: Date.now(),
174
+ });
175
+ else if (info.incomplete)
176
+ deps.tui.addMessage({
177
+ id: `incomplete-${Date.now()}`,
178
+ role: "system",
179
+ content: "还没有完整回复,发送消息可继续",
180
+ timestamp: Date.now(),
181
+ });
73
182
  },
74
183
  };
75
184
  try {
185
+ setSubAgentListener((ev) => {
186
+ if (ev.phase !== "call")
187
+ return;
188
+ deps.tui.appendToolChild(ev.parentToolCallId, {
189
+ name: ev.name,
190
+ summary: summarizeToolCall(ev.name, ev.input),
191
+ });
192
+ });
76
193
  const runOptions = {
77
194
  providerName: deps.getProvider(),
195
+ taskState: deps.getTaskState(),
196
+ onStepPersist: () => saveThrottle.request(),
78
197
  ...(deps.getPlanMode() ? { planMode: true } : {}),
79
198
  };
80
- if (deps.mode === "code" && deps.codeSystemPrompt) {
81
- await runOnceWithSystem(deps.messages, deps.codeSystemPrompt, deps.getModel(), abortController?.signal, callbacks, deps.tracker, runOptions);
82
- }
83
- else {
84
- const { loadInstructions } = await import("../instructions.js");
85
- const instructions = await loadInstructions();
86
- await runOnce(deps.messages, instructions, deps.getModel(), abortController?.signal, callbacks, deps.tracker, runOptions);
87
- }
199
+ await runOnceWithSystem(deps.messages, deps.getSystemPrompt(), deps.getModel(), abortController?.signal, callbacks, deps.tracker, runOptions);
88
200
  }
89
201
  catch (err) {
90
202
  if (!abortController)
@@ -93,10 +205,18 @@ export function createAgentRunner(deps) {
93
205
  callbacks.onStreamError(msg);
94
206
  }
95
207
  finally {
208
+ unsubscribeTokens();
209
+ if (tokenTimer != null) {
210
+ clearTimeout(tokenTimer);
211
+ tokenTimer = null;
212
+ }
213
+ setSubAgentListener(null);
214
+ saveThrottle.cancel();
96
215
  if (!compactedDuringRun)
97
216
  deps.setUndoStack(pushTurn(deps.getUndoStack(), turnStart, deps.messages.length, uiStart));
98
217
  abortController = null;
99
218
  deps.tui.setRunning(false);
219
+ await flushTokenInfo();
100
220
  }
101
221
  };
102
222
  return { run, abort, getController };
@@ -1,4 +1,4 @@
1
- import { charWidth, wrapByWidth } from "./text-width.js";
1
+ import { displayWidth, wrapByWidth, clustersOf } from "./text-width.js";
2
2
  /** Split the value into visual rows, tracking each row's character start index (newlines count as one). */
3
3
  export function visualRows(value, maxWidth) {
4
4
  const rows = [];
@@ -32,13 +32,12 @@ export function caretIndexFromClick(rows, rowIndex, clickColumn, textStartColumn
32
32
  return row.startIndex;
33
33
  let width = 0;
34
34
  let idx = 0;
35
- for (const char of row.text) {
36
- const w = charWidth(char);
35
+ for (const { text: g, width: w } of clustersOf(row.text)) {
37
36
  if (width + w > target) {
38
- return row.startIndex + idx + (target - width < w / 2 ? 0 : 1);
37
+ return row.startIndex + idx + (target - width < w / 2 ? 0 : Array.from(g).length);
39
38
  }
40
39
  width += w;
41
- idx++;
40
+ idx += Array.from(g).length;
42
41
  }
43
42
  return row.startIndex + idx;
44
43
  }
@@ -64,6 +63,30 @@ export function deleteAt(value, caretIndex) {
64
63
  chars.splice(caretIndex, 1);
65
64
  return { value: chars.join(""), caretIndex };
66
65
  }
66
+ /** Move the caret one visual row up (`-1`) or down (`1`), keeping the display column. */
67
+ export function moveCaretVertical(value, caretIndex, direction, maxWidth) {
68
+ const rows = visualRows(value, maxWidth);
69
+ let rowIndex = 0;
70
+ let offset = 0;
71
+ for (let i = 0; i < rows.length; i++) {
72
+ const row = rows[i];
73
+ const end = row.startIndex + Array.from(row.text).length;
74
+ if (caretIndex >= row.startIndex && caretIndex <= end) {
75
+ rowIndex = i;
76
+ offset = caretIndex - row.startIndex;
77
+ break;
78
+ }
79
+ if (i === rows.length - 1) {
80
+ rowIndex = i;
81
+ offset = Array.from(row.text).length;
82
+ }
83
+ }
84
+ const next = rowIndex + direction;
85
+ if (next < 0 || next >= rows.length)
86
+ return caretIndex;
87
+ const col = displayWidth(Array.from(rows[rowIndex].text).slice(0, offset).join(""));
88
+ return caretIndexFromClick(rows, next, col, 0);
89
+ }
67
90
  export function moveToLineStart(value, caretIndex) {
68
91
  const chars = Array.from(value);
69
92
  let i = caretIndex - 1;
@@ -132,3 +155,23 @@ export function scanDeleteKeys(buffer, chunk) {
132
155
  const prefix = tail.slice(esc);
133
156
  return { buffer: prefix.length > MAX_DELETE_PREFIX ? "\x1b" : prefix, actions };
134
157
  }
158
+ const HOME_END_RE = /\x1b\[(?:[HF]|[1478]~)/g;
159
+ const MAX_HOME_END_PREFIX = 8;
160
+ export function scanHomeEndKeys(buffer, chunk) {
161
+ const buf = buffer + chunk;
162
+ const actions = [];
163
+ let lastConsumedEnd = 0;
164
+ HOME_END_RE.lastIndex = 0;
165
+ let m;
166
+ while ((m = HOME_END_RE.exec(buf))) {
167
+ lastConsumedEnd = m.index + m[0].length;
168
+ const seq = m[0];
169
+ actions.push(seq.endsWith("H") || seq.endsWith("1~") || seq.endsWith("7~") ? { kind: "home" } : { kind: "end" });
170
+ }
171
+ const tail = buf.slice(lastConsumedEnd);
172
+ const esc = tail.lastIndexOf("\x1b");
173
+ if (esc === -1)
174
+ return { buffer: "", actions };
175
+ const prefix = tail.slice(esc);
176
+ return { buffer: prefix.length > MAX_HOME_END_PREFIX ? "\x1b" : prefix, actions };
177
+ }
package/dist/tui/caret.js CHANGED
@@ -26,7 +26,7 @@ export function setCaretPosition(position) {
26
26
  export function createCaretAwareStdout(base) {
27
27
  let pendingRowsUp = 0;
28
28
  const write = (chunk, encoding, callback) => {
29
- const isBuf = typeof Buffer !== "undefined" && Buffer.isBuffer(chunk);
29
+ const isBuf = Buffer?.isBuffer(chunk);
30
30
  let chunkStr;
31
31
  if (typeof chunk === "string")
32
32
  chunkStr = chunk;
@@ -0,0 +1,13 @@
1
+ export const DOUBLE_CLICK_MS = 400;
2
+ export const DOUBLE_CLICK_COL_SLOP = 2;
3
+ export function createClickCounter() {
4
+ return { row: -1, col: -1, at: Number.NEGATIVE_INFINITY, count: 0 };
5
+ }
6
+ export function noteClick(c, ev) {
7
+ const same = ev.row === c.row && Math.abs(ev.col - c.col) <= DOUBLE_CLICK_COL_SLOP && ev.at - c.at <= DOUBLE_CLICK_MS;
8
+ c.count = same ? c.count + 1 : 1;
9
+ c.row = ev.row;
10
+ c.col = ev.col;
11
+ c.at = ev.at;
12
+ return c.count;
13
+ }
@@ -23,7 +23,10 @@ export function stepDrag(machine, event, maxHeight) {
23
23
  const plain = (button & MOD_MASK) === 0;
24
24
  const dragging = machine.pending != null;
25
25
  if (plain && dragging && ((button & MOTION) !== 0 || (button === 0 && press))) {
26
- machine.selection = { anchor: machine.pending, cur: { row, col } };
26
+ const origin = machine.pending;
27
+ if (row === origin.row && col === origin.col && machine.selection == null)
28
+ return null;
29
+ machine.selection = { anchor: origin, cur: { row, col } };
27
30
  return { type: "selection", selection: machine.selection };
28
31
  }
29
32
  if (button === 0 && press) {
@@ -35,10 +38,12 @@ export function stepDrag(machine, event, maxHeight) {
35
38
  const active = machine.selection;
36
39
  const pending = machine.pending;
37
40
  machine.pending = null;
38
- if (active)
41
+ machine.selection = null;
42
+ if (active && (active.anchor.row !== active.cur.row || active.anchor.col !== active.cur.col)) {
39
43
  return { type: "copy", selection: active };
44
+ }
40
45
  if (pending)
41
- return { type: "click", row };
46
+ return { type: "click", row: pending.row, col: pending.col };
42
47
  }
43
48
  return null;
44
49
  }