min-agent 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +397 -139
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
@@ -0,0 +1,104 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { wrapByWidth } from "./text-width.js";
5
+ import { innerTextWidth } from "./layout.js";
6
+ import { theme } from "./theme.js";
7
+ /**
8
+ * 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.
11
+ */
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 */
22
+ }
23
+ /** 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
+ const [value, setValue] = useState("");
26
+ const [caret, setCaret] = useState(0);
27
+ useInput((input, key) => {
28
+ if (key.escape) {
29
+ onAnswer(null);
30
+ return;
31
+ }
32
+ if (key.return) {
33
+ if (/^\d+$/.test(value.trim()) && options?.length) {
34
+ const n = parseInt(value.trim(), 10);
35
+ if (n >= 1 && n <= options.length) {
36
+ onAnswer(options[n - 1]);
37
+ return;
38
+ }
39
+ }
40
+ onAnswer(value.trim());
41
+ return;
42
+ }
43
+ if (key.leftArrow) {
44
+ setCaret((c) => Math.max(0, c - 1));
45
+ return;
46
+ }
47
+ if (key.rightArrow) {
48
+ setCaret((c) => Math.min(Array.from(value).length, c + 1));
49
+ return;
50
+ }
51
+ if (key.backspace || key.delete) {
52
+ if (key.backspace) {
53
+ if (caret === 0)
54
+ return;
55
+ const chars = Array.from(value);
56
+ chars.splice(caret - 1, 1);
57
+ setValue(chars.join(""));
58
+ setCaret((c) => c - 1);
59
+ }
60
+ else {
61
+ const chars = Array.from(value);
62
+ if (caret >= chars.length)
63
+ return;
64
+ chars.splice(caret, 1);
65
+ setValue(chars.join(""));
66
+ }
67
+ return;
68
+ }
69
+ if (key.ctrl && input === "a") {
70
+ setCaret(0);
71
+ return;
72
+ }
73
+ if (key.ctrl && input === "e") {
74
+ setCaret(Array.from(value).length);
75
+ return;
76
+ }
77
+ if (key.ctrl && input === "u") {
78
+ setValue("");
79
+ setCaret(0);
80
+ return;
81
+ }
82
+ if (key.ctrl && input === "k") {
83
+ const chars = Array.from(value);
84
+ chars.splice(caret);
85
+ setValue(chars.join(""));
86
+ return;
87
+ }
88
+ if (key.ctrl || key.meta)
89
+ return;
90
+ if (key.upArrow || key.downArrow || key.tab)
91
+ return;
92
+ if (input) {
93
+ const clean = input.replace(/[\r\n]+$/, "");
94
+ if (!clean)
95
+ return;
96
+ const chars = Array.from(value);
97
+ const ins = Array.from(clean);
98
+ chars.splice(caret, 0, ...ins);
99
+ setValue(chars.join(""));
100
+ setCaret((c) => c + ins.length);
101
+ }
102
+ });
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" }) })] }));
104
+ }
@@ -1,16 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function StatusBar({ state }) {
3
+ import { displayWidth } from "./text-width.js";
4
+ export function StatusBar({ state, columns = 80 }) {
4
5
  const model = state.model || "unknown";
5
6
  const tokens = state.tokenInfo;
6
- let bar = "";
7
- let barColor = "green";
8
- if (tokens && tokens.contextWindow > 0) {
9
- const pct = Math.round((tokens.input / tokens.contextWindow) * 100);
10
- const filled = Math.round(pct / 10);
11
- const empty = 10 - filled;
12
- bar = `${tokens.input}/${tokens.contextWindow} ${"█".repeat(filled)}${"░".repeat(empty)} ${pct}%`;
13
- barColor = pct >= 80 ? "red" : pct >= 50 ? "yellow" : "green";
14
- }
15
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "─".repeat(60) }), _jsxs(Box, { children: [_jsx(Text, { children: " \uD83E\uDD16 min-agent" }), _jsxs(Text, { color: "gray", children: [" (", model, ")"] }), bar && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: barColor, children: bar })] }))] })] }));
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
+ : "";
16
+ // Transient copy feedback rides on the separator row itself (right end) so
17
+ // it never adds a row and the layout does not jump while it is visible.
18
+ const sepWidth = Math.max(8, columns - 2);
19
+ const notice = state.copyNotice ? `✓ ${state.copyNotice}` : null;
20
+ 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 })] }))] })] }));
16
24
  }
@@ -0,0 +1,103 @@
1
+ export function createAgentRunner(deps) {
2
+ let abortController = null;
3
+ const getController = () => abortController;
4
+ const abort = (tui) => {
5
+ if (!abortController)
6
+ return false;
7
+ import("../tools/bash.js").then((m) => m.killActiveProcesses()).catch(() => { });
8
+ abortController.abort();
9
+ abortController = null;
10
+ tui.setRunning(false);
11
+ tui.addMessage({ id: `sys-${Date.now()}`, role: "system", content: "(已取消)", timestamp: Date.now() });
12
+ return true;
13
+ };
14
+ const run = async () => {
15
+ const turnStart = deps.messages.length;
16
+ const uiStart = deps.tui.messageCount();
17
+ let compactedDuringRun = false;
18
+ abortController = new AbortController();
19
+ deps.tui.setRunning(true, "思考中...");
20
+ const { summarizeToolCall, summarizeToolResult, toolResultText } = await import("../tool-display.js");
21
+ const { getContextWindow } = await import("../context-window.js");
22
+ const { getModelPrice, estimateCost, formatCost } = await import("../pricing.js");
23
+ const { runOnce, runOnceWithSystem } = await import("../agent.js");
24
+ const { clearStack, pushTurn } = await import("./undo-stack.js");
25
+ const callbacks = {
26
+ onAssistantDisplayDelta: (delta) => deps.tui.appendToLast(delta),
27
+ onThinkingDelta: (delta) => {
28
+ deps.tui.setRunning(true, "推理中...");
29
+ deps.tui.appendToThinking(delta);
30
+ },
31
+ onToolCall: (toolName, input, toolCallId) => {
32
+ deps.tui.setRunning(true, `执行 ${toolName}...`);
33
+ deps.tui.addMessage({
34
+ id: `tool-${toolCallId || `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`}`,
35
+ role: "tool",
36
+ content: summarizeToolCall(toolName, input),
37
+ toolName,
38
+ toolCallId,
39
+ timestamp: Date.now(),
40
+ });
41
+ },
42
+ onToolResult: (toolName, output, meta) => {
43
+ deps.tui.setRunning(true, "思考中...");
44
+ deps.tui.setToolResult(meta.toolCallId, toolName, summarizeToolResult(toolName, output, meta.isError), toolResultText(output), meta.isError);
45
+ },
46
+ onStreamError: (msg) => {
47
+ deps.tui.addMessage({ id: `err-${Date.now()}`, role: "system", content: `错误: ${msg}`, timestamp: Date.now() });
48
+ },
49
+ onCompaction: () => {
50
+ compactedDuringRun = true;
51
+ deps.setUndoStack(clearStack(deps.getUndoStack()));
52
+ },
53
+ onRunFinish: async (info) => {
54
+ abortController = null;
55
+ deps.tui.setRunning(false);
56
+ if (info.aborted)
57
+ return;
58
+ 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);
67
+ 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() });
70
+ }
71
+ if (info.maxStepsReached)
72
+ deps.tui.addMessage({ id: `maxsteps-${Date.now()}`, role: "system", content: "⚠ 达到本轮 30 步上限,发送消息继续", timestamp: Date.now() });
73
+ },
74
+ };
75
+ try {
76
+ const runOptions = {
77
+ providerName: deps.getProvider(),
78
+ ...(deps.getPlanMode() ? { planMode: true } : {}),
79
+ };
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
+ }
88
+ }
89
+ catch (err) {
90
+ if (!abortController)
91
+ return;
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ callbacks.onStreamError(msg);
94
+ }
95
+ finally {
96
+ if (!compactedDuringRun)
97
+ deps.setUndoStack(pushTurn(deps.getUndoStack(), turnStart, deps.messages.length, uiStart));
98
+ abortController = null;
99
+ deps.tui.setRunning(false);
100
+ }
101
+ };
102
+ return { run, abort, getController };
103
+ }
@@ -0,0 +1,134 @@
1
+ import { charWidth, wrapByWidth } from "./text-width.js";
2
+ /** Split the value into visual rows, tracking each row's character start index (newlines count as one). */
3
+ export function visualRows(value, maxWidth) {
4
+ const rows = [];
5
+ let start = 0;
6
+ const lines = value.split("\n");
7
+ for (let i = 0; i < lines.length; i++) {
8
+ for (const row of wrapByWidth(lines[i], maxWidth)) {
9
+ rows.push({ text: row, startIndex: start });
10
+ start += Array.from(row).length;
11
+ }
12
+ if (i < lines.length - 1)
13
+ start += 1;
14
+ }
15
+ return rows;
16
+ }
17
+ /** Move the caret by one code point; clamps at the start/end of the value. */
18
+ export function moveCaretHorizontal(value, caretIndex, direction) {
19
+ const len = Array.from(value).length;
20
+ return Math.min(Math.max(caretIndex + direction, 0), len);
21
+ }
22
+ /**
23
+ * Map a click column (1-based terminal column) on a visual row to a character
24
+ * index in the value. Fullwidth characters snap to the nearer boundary.
25
+ */
26
+ export function caretIndexFromClick(rows, rowIndex, clickColumn, textStartColumn) {
27
+ const row = rows[rowIndex];
28
+ if (!row)
29
+ return 0;
30
+ const target = clickColumn - textStartColumn;
31
+ if (target <= 0)
32
+ return row.startIndex;
33
+ let width = 0;
34
+ let idx = 0;
35
+ for (const char of row.text) {
36
+ const w = charWidth(char);
37
+ if (width + w > target) {
38
+ return row.startIndex + idx + (target - width < w / 2 ? 0 : 1);
39
+ }
40
+ width += w;
41
+ idx++;
42
+ }
43
+ return row.startIndex + idx;
44
+ }
45
+ /** Insert text at the caret; returns the new value and caret index. */
46
+ export function insertAt(value, caretIndex, text) {
47
+ const chars = Array.from(value);
48
+ chars.splice(caretIndex, 0, ...Array.from(text));
49
+ return { value: chars.join(""), caretIndex: caretIndex + Array.from(text).length };
50
+ }
51
+ /** Remove the code point before the caret. */
52
+ export function backspaceAt(value, caretIndex) {
53
+ if (caretIndex <= 0)
54
+ return { value, caretIndex };
55
+ const chars = Array.from(value);
56
+ chars.splice(caretIndex - 1, 1);
57
+ return { value: chars.join(""), caretIndex: caretIndex - 1 };
58
+ }
59
+ /** Remove the code point at the caret. */
60
+ export function deleteAt(value, caretIndex) {
61
+ const chars = Array.from(value);
62
+ if (caretIndex >= chars.length)
63
+ return { value, caretIndex };
64
+ chars.splice(caretIndex, 1);
65
+ return { value: chars.join(""), caretIndex };
66
+ }
67
+ export function moveToLineStart(value, caretIndex) {
68
+ const chars = Array.from(value);
69
+ let i = caretIndex - 1;
70
+ while (i >= 0 && chars[i] !== "\n")
71
+ i--;
72
+ return i + 1;
73
+ }
74
+ export function moveToLineEnd(value, caretIndex) {
75
+ const chars = Array.from(value);
76
+ let i = caretIndex;
77
+ while (i < chars.length && chars[i] !== "\n")
78
+ i++;
79
+ return i;
80
+ }
81
+ export function deleteToLineStart(value, caretIndex) {
82
+ const start = moveToLineStart(value, caretIndex);
83
+ if (start === caretIndex)
84
+ return { value, caretIndex };
85
+ const chars = Array.from(value);
86
+ chars.splice(start, caretIndex - start);
87
+ return { value: chars.join(""), caretIndex: start };
88
+ }
89
+ export function deleteToLineEnd(value, caretIndex) {
90
+ const end = moveToLineEnd(value, caretIndex);
91
+ if (end === caretIndex)
92
+ return { value, caretIndex };
93
+ const chars = Array.from(value);
94
+ chars.splice(caretIndex, end - caretIndex);
95
+ return { value: chars.join(""), caretIndex };
96
+ }
97
+ export function deleteWordBefore(value, caretIndex) {
98
+ if (caretIndex <= 0)
99
+ return { value, caretIndex };
100
+ const chars = Array.from(value);
101
+ let i = caretIndex - 1;
102
+ while (i >= 0 && /\s/.test(chars[i]))
103
+ i--;
104
+ while (i >= 0 && !/\s/.test(chars[i]))
105
+ i--;
106
+ const start = i + 1;
107
+ chars.splice(start, caretIndex - start);
108
+ return { value: chars.join(""), caretIndex: start };
109
+ }
110
+ /** The longest escape sequence this scanner recognises, kept for the cap. */
111
+ const MAX_DELETE_PREFIX = 32;
112
+ /**
113
+ * Scan raw stdin bytes for Backspace (DEL, 0x7F — what most terminals send
114
+ * for the Backspace key) and Delete sequences (CSI 3 ~ and its modifier
115
+ * variants like CSI 3;5 ~ for Ctrl+Delete), which may span chunks. Ink
116
+ * reports all of them as `key.delete`, so they are distinguished here.
117
+ */
118
+ export function scanDeleteKeys(buffer, chunk) {
119
+ const buf = buffer + chunk;
120
+ const actions = [];
121
+ const re = /\x1b\[3(?:;\d+)?[~$^]|\x7f/g;
122
+ let lastConsumedEnd = 0;
123
+ let m;
124
+ while ((m = re.exec(buf))) {
125
+ lastConsumedEnd = m.index + m[0].length;
126
+ actions.push(m[0] === "\x7f" ? { kind: "backspace" } : { kind: "delete" });
127
+ }
128
+ const tail = buf.slice(lastConsumedEnd);
129
+ const esc = tail.lastIndexOf("\x1b");
130
+ if (esc === -1)
131
+ return { buffer: "", actions };
132
+ const prefix = tail.slice(esc);
133
+ return { buffer: prefix.length > MAX_DELETE_PREFIX ? "\x1b" : prefix, actions };
134
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Real terminal caret tracking for the TUI.
3
+ *
4
+ * Ink draws frames with `log-update`, which hides the hardware cursor and
5
+ * always leaves it at the end of the frame. That is fine for ASCII typing
6
+ * (the input bar paints its own `█` block), but an IME draws its preedit text
7
+ * and candidate window at the *hardware* cursor. With the cursor parked below
8
+ * the input box, Chinese input appears in the wrong place.
9
+ *
10
+ * The fix: after every frame Ink writes, move the hardware cursor onto the cell
11
+ * where the input bar painted its caret, and undo that move right before the
12
+ * next frame so Ink's own relative erase bookkeeping stays intact.
13
+ */
14
+ let caret = null;
15
+ /** Called by the input bar on every render; `null` disables caret tracking. */
16
+ export function setCaretPosition(position) {
17
+ caret = position;
18
+ }
19
+ /**
20
+ * Wrap a TTY stream so Ink's writes are followed by a caret move.
21
+ *
22
+ * Only the row offset needs undoing: Ink's frames always end at column 1
23
+ * (either after a trailing newline, or after `clearTerminal` repositions the
24
+ * cursor absolutely), so `\r` is enough to restore the column.
25
+ */
26
+ export function createCaretAwareStdout(base) {
27
+ let pendingRowsUp = 0;
28
+ const write = (chunk, encoding, callback) => {
29
+ const isBuf = typeof Buffer !== "undefined" && Buffer.isBuffer(chunk);
30
+ let chunkStr;
31
+ if (typeof chunk === "string")
32
+ chunkStr = chunk;
33
+ else if (isBuf)
34
+ chunkStr = chunk.toString(encoding || "utf-8");
35
+ else if (chunk != null)
36
+ chunkStr = String(chunk);
37
+ else
38
+ return base.write(chunk, encoding, callback);
39
+ let out = "";
40
+ if (pendingRowsUp > 0) {
41
+ out += `\x1b[${pendingRowsUp}B\r`;
42
+ pendingRowsUp = 0;
43
+ }
44
+ out += chunkStr;
45
+ const position = caret;
46
+ if (position) {
47
+ const rowsUp = position.rowsAbove + (chunkStr.endsWith("\n") ? 1 : 0);
48
+ if (rowsUp > 0) {
49
+ out += `\x1b[${rowsUp}A\x1b[${position.column}G`;
50
+ pendingRowsUp = rowsUp;
51
+ }
52
+ }
53
+ const finalChunk = typeof chunk === "string" ? out : Buffer.from(out, encoding || "utf-8");
54
+ return base.write(finalChunk, encoding, callback);
55
+ };
56
+ return new Proxy(base, {
57
+ get(target, prop) {
58
+ if (prop === "write")
59
+ return write;
60
+ // Read through to the real stream: getters like `columns`/`rows` and
61
+ // methods like `on`/`off` must run with the stream as `this`.
62
+ const value = Reflect.get(target, prop, target);
63
+ return typeof value === "function" ? value.bind(target) : value;
64
+ },
65
+ set(target, prop, value) {
66
+ return Reflect.set(target, prop, value, target);
67
+ },
68
+ });
69
+ }
@@ -0,0 +1,61 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ const MAX_DIFF_CHARS = 8000;
4
+ const execFileAsync = promisify(execFile);
5
+ export async function getWorkingTreeDiff(cwd = process.cwd(), run = defaultRun) {
6
+ try {
7
+ await run("git rev-parse --is-inside-work-tree", cwd);
8
+ }
9
+ catch {
10
+ return { ok: false, reason: "not_git", message: "当前目录不是 git 仓库" };
11
+ }
12
+ let stat = "";
13
+ let full = "";
14
+ try {
15
+ stat = await run("git diff --stat", cwd);
16
+ full = await run("git diff", cwd);
17
+ }
18
+ catch (err) {
19
+ return { ok: false, reason: "error", message: err instanceof Error ? err.message : String(err) };
20
+ }
21
+ if (!full.trim())
22
+ return { ok: false, reason: "no_changes", message: "工作区无未提交变更" };
23
+ const body = full.length > MAX_DIFF_CHARS ? full.slice(0, MAX_DIFF_CHARS) + "\n… [diff 过长,已截断]" : full;
24
+ return { ok: true, diff: stat ? `${stat}\n\n${body}` : body };
25
+ }
26
+ async function defaultRun(cmd, cwd) {
27
+ const parts = parseCmd(cmd);
28
+ const { stdout } = await execFileAsync(parts[0], parts.slice(1), {
29
+ cwd,
30
+ encoding: "utf-8",
31
+ timeout: 10000,
32
+ maxBuffer: 10 * 1024 * 1024,
33
+ });
34
+ return stdout;
35
+ }
36
+ function parseCmd(cmd) {
37
+ const out = [];
38
+ let cur = "";
39
+ let q = null;
40
+ for (const ch of cmd) {
41
+ if (q) {
42
+ if (ch === q)
43
+ q = null;
44
+ else
45
+ cur += ch;
46
+ }
47
+ else if (ch === '"' || ch === "'")
48
+ q = ch;
49
+ else if (ch === " ") {
50
+ if (cur) {
51
+ out.push(cur);
52
+ cur = "";
53
+ }
54
+ }
55
+ else
56
+ cur += ch;
57
+ }
58
+ if (cur)
59
+ out.push(cur);
60
+ return out;
61
+ }
@@ -0,0 +1,44 @@
1
+ import { MOTION, MOD_MASK } from "./mouse.js";
2
+ export function createDragMachine() {
3
+ return { pending: null, selection: null };
4
+ }
5
+ export function clearDrag(machine) {
6
+ machine.pending = null;
7
+ machine.selection = null;
8
+ }
9
+ /**
10
+ * Advance the drag state machine on one SGR mouse event (wheel events are
11
+ * filtered by the caller before this runs). `maxHeight` is the full region
12
+ * allocated to the message list, not just its rendered rows — a press on the
13
+ * blank gap below a short conversation still starts a drag there, and the
14
+ * selection math clamps out-of-range rows.
15
+ *
16
+ * Terminals disagree on drag motion: xterm sends button + 32 (MOTION), but
17
+ * Apple Terminal reports it as a plain button-0 press. While a drag is in
18
+ * progress either form extends the selection instead of resetting it —
19
+ * otherwise every move would re-anchor and clear the highlight.
20
+ */
21
+ export function stepDrag(machine, event, maxHeight) {
22
+ const { button, row, col, press } = event;
23
+ const plain = (button & MOD_MASK) === 0;
24
+ const dragging = machine.pending != null;
25
+ if (plain && dragging && ((button & MOTION) !== 0 || (button === 0 && press))) {
26
+ machine.selection = { anchor: machine.pending, cur: { row, col } };
27
+ return { type: "selection", selection: machine.selection };
28
+ }
29
+ if (button === 0 && press) {
30
+ machine.selection = null;
31
+ machine.pending = row >= 1 && row <= maxHeight ? { row, col } : null;
32
+ return { type: "selection", selection: null };
33
+ }
34
+ if (button === 0 && !press) {
35
+ const active = machine.selection;
36
+ const pending = machine.pending;
37
+ machine.pending = null;
38
+ if (active)
39
+ return { type: "copy", selection: active };
40
+ if (pending)
41
+ return { type: "click", row };
42
+ }
43
+ return null;
44
+ }