min-agent 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
@@ -1,33 +1,687 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import React, { useMemo, useEffect, useRef, useState, useCallback } from "react";
3
+ import { Box, Text, useInput, useStdin } from "ink";
3
4
  import { MarkdownRenderer } from "../markdown.js";
5
+ import { wrapByWidth, clustersOf, displayWidth } from "./text-width.js";
6
+ import { messageTextWidth } from "./layout.js";
7
+ import { scanSgrMouse, WHEEL_UP, WHEEL_DOWN, MOTION } from "./mouse.js";
8
+ import { applySelection, selectionRanges, stripAnsi, textFromSelection, wordRangeAt, mouseToCell, splitRangeAt, alignPartSelections, visualPrefix, } from "./selection.js";
9
+ import { createDragMachine, clearDrag, stepDrag } from "./drag-state.js";
10
+ import { createClickCounter, noteClick } from "./click-count.js";
11
+ import { writeClipboard } from "../clipboard.js";
12
+ const RESET = "\x1b[0m";
13
+ /** Split a single ANSI-formatted line into physical rows of at most `maxWidth`
14
+ * cells. Each row re-applies the styles active at its start and ends with a
15
+ * reset, so a window sliced in the middle of a message keeps correct colors. */
16
+ function splitAnsiRows(line, maxWidth) {
17
+ const rows = [];
18
+ let current = "";
19
+ let width = 0;
20
+ let prefix = "";
21
+ for (const token of line.split(/(\x1b\[[0-9;]*m)/g)) {
22
+ if (token.startsWith("\x1b[")) {
23
+ if (token === RESET)
24
+ prefix = "";
25
+ else
26
+ prefix += token;
27
+ current += token;
28
+ continue;
29
+ }
30
+ for (const { text: g, width: w } of clustersOf(token)) {
31
+ if (width > 0 && width + w > maxWidth) {
32
+ rows.push(current + RESET);
33
+ current = prefix + g;
34
+ width = w;
35
+ }
36
+ else {
37
+ current += g;
38
+ width += w;
39
+ }
40
+ }
41
+ }
42
+ rows.push(current + RESET);
43
+ return rows;
44
+ }
45
+ /** Longest prefix of `text` that fits in `maxWidth` cells. */
46
+ function clipToWidth(text, maxWidth) {
47
+ let shown = "";
48
+ let width = 0;
49
+ for (const { text: g, width: w } of clustersOf(text)) {
50
+ if (width + w > maxWidth)
51
+ break;
52
+ shown += g;
53
+ width += w;
54
+ }
55
+ return shown;
56
+ }
57
+ /** Rows shown for an expanded tool result — enough to read a result list. */
58
+ const TOOL_RESULT_MAX_LINES = 120;
59
+ /**
60
+ * Cap an expanded tool result so a 50KB output cannot take over the viewport.
61
+ * Tools already persist their full output to disk when they truncate.
62
+ */
63
+ function resultPreviewLines(result) {
64
+ const lines = result.replace(/\s+$/, "").split("\n");
65
+ if (lines.length <= TOOL_RESULT_MAX_LINES)
66
+ return lines;
67
+ const hidden = lines.length - TOOL_RESULT_MAX_LINES;
68
+ return [...lines.slice(0, TOOL_RESULT_MAX_LINES), `… 结果还有 ${hidden} 行未显示`];
69
+ }
70
+ /** Physical rows for one message. The leading marker ("> " / "⚡ name ") lives
71
+ * in the first row only, mirroring the JSX layout below.
72
+ *
73
+ * Tool calls render as a single row (arguments plus a result summary) by
74
+ * default; clicking the row (see MessageList) toggles `expanded` to reveal the
75
+ * full tool result. */
76
+ export function messageLines(msg, columns, expanded) {
77
+ const textWidth = messageTextWidth(columns);
78
+ switch (msg.role) {
79
+ case "user":
80
+ return wrapByWidth(`> ${msg.content}`, textWidth);
81
+ case "assistant": {
82
+ try {
83
+ const md = new MarkdownRenderer();
84
+ const rendered = md.write(typeof msg.content === "string" ? msg.content : String(msg.content ?? "")) + md.flush();
85
+ const raw = rendered.replace(/\n$/, "");
86
+ if (raw === "")
87
+ return [];
88
+ const lines = raw.split("\n");
89
+ let start = 0;
90
+ let end = lines.length;
91
+ while (start < end && lines[start].trim() === "")
92
+ start++;
93
+ while (end > start && lines[end - 1].trim() === "")
94
+ end--;
95
+ if (start === end)
96
+ return [];
97
+ const rows = [];
98
+ for (let i = start; i < end; i++)
99
+ rows.push(...splitAnsiRows(lines[i], textWidth));
100
+ return rows;
101
+ }
102
+ catch {
103
+ return wrapByWidth(typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), textWidth);
104
+ }
105
+ }
106
+ case "thinking":
107
+ return msg.content?.trim() ? wrapByWidth(msg.content, textWidth) : [];
108
+ case "tool": {
109
+ const name = msg.toolName ?? "";
110
+ // The call summary is a single logical line; collapse any stray newlines
111
+ // so it cannot push the row layout around.
112
+ const call = msg.content.replace(/\s+/g, " ").trim();
113
+ const label = `⚡ ${name} ${expanded ? "▾" : "▸"} `;
114
+ const contentWidth = Math.max(1, textWidth - displayWidth(label));
115
+ if (!expanded) {
116
+ const tail = msg.toolResultSummary ? ` → ${msg.toolResultSummary}` : "";
117
+ const packed = displayWidth(call + tail) <= contentWidth
118
+ ? `${label}${call}${tail}`
119
+ : `${label}${clipToWidth(call, Math.max(1, contentWidth - displayWidth(tail) - 1))}…${tail}`;
120
+ return [clipToWidth(packed, textWidth)];
121
+ }
122
+ const rows = wrapByWidth(`${label}${call}`, textWidth);
123
+ if (msg.toolChildren && msg.toolChildren.length > 0) {
124
+ for (const child of msg.toolChildren) {
125
+ const summary = child.summary ? ` ${child.summary}` : "";
126
+ rows.push(...wrapByWidth(` ↳ ${child.name}${summary}`, textWidth));
127
+ }
128
+ }
129
+ const result = msg.toolResult;
130
+ if (result && result.trim() !== "") {
131
+ for (const line of resultPreviewLines(result)) {
132
+ rows.push(...wrapByWidth(` ${line}`, textWidth));
133
+ }
134
+ }
135
+ return rows;
136
+ }
137
+ default: {
138
+ if (!msg.content)
139
+ return [];
140
+ const rows = [];
141
+ for (const line of msg.content.split("\n"))
142
+ rows.push(...wrapByWidth(line, textWidth));
143
+ return rows;
144
+ }
145
+ }
146
+ }
147
+ // Streaming appends mutate the last assistant message every frame; keying on
148
+ // id + content means the cache stays valid for the exact content rendered.
149
+ const LINE_CACHE_LIMIT = 256;
150
+ const lineCache = new Map();
151
+ function hashContent(s) {
152
+ let h = 0;
153
+ for (let i = 0; i < s.length; i++)
154
+ h = (h * 31 + s.charCodeAt(i)) | 0;
155
+ return String(h);
156
+ }
157
+ function cacheKey(msg, columns, expanded) {
158
+ return [
159
+ msg.id,
160
+ String(msg.content.length),
161
+ hashContent(msg.content.slice(0, 64) + msg.content.slice(-32)),
162
+ String(msg.toolResult?.length ?? 0),
163
+ msg.toolResult ? hashContent(msg.toolResult.slice(0, 64)) : "",
164
+ String(columns),
165
+ expanded ? "1" : "0",
166
+ msg.toolName ?? "",
167
+ msg.toolResultSummary ?? "",
168
+ (msg.toolChildren ?? []).map((c) => `${c.name}:${c.summary}`).join("|"),
169
+ ].join("\u0000");
170
+ }
171
+ function cachedMessageLines(msg, columns, expanded) {
172
+ const key = cacheKey(msg, columns, expanded);
173
+ const hit = lineCache.get(key);
174
+ if (hit !== undefined)
175
+ return hit;
176
+ const lines = messageLines(msg, columns, expanded);
177
+ if (lineCache.size >= LINE_CACHE_LIMIT) {
178
+ const first = lineCache.keys().next().value;
179
+ if (first !== undefined)
180
+ lineCache.delete(first);
181
+ }
182
+ lineCache.set(key, lines);
183
+ return lines;
184
+ }
185
+ /** User messages carry a 1-row margin above them. */
186
+ const MARGIN = 1;
187
+ function messageRowCount(msg, columns, expanded) {
188
+ return cachedMessageLines(msg, columns, expanded).length + (msg.role === "user" ? MARGIN : 0);
189
+ }
190
+ /** Rows a window actually paints: content plus one indicator per hidden side. */
191
+ export function slicePaintRows(slice) {
192
+ return slice.winBottom - slice.winTop + (slice.above > 0 ? 1 : 0) + (slice.below > 0 ? 1 : 0);
193
+ }
194
+ /**
195
+ * Compute the visible window of a message list for `maxHeight` rows, given
196
+ * the physical row count of each message and an optional scroll offset
197
+ * (`null` = stick to the bottom). Each hidden side (above / below) takes its
198
+ * own indicator row, and those rows are subtracted from the content budget
199
+ * so the painted window never exceeds `maxHeight`.
200
+ */
201
+ export function sliceWindow(messages, rowCounts, maxHeight, top) {
202
+ const total = rowCounts.reduce((a, b) => a + b, 0);
203
+ if (maxHeight <= 0) {
204
+ return {
205
+ winTop: 0,
206
+ winBottom: total,
207
+ above: 0,
208
+ below: 0,
209
+ parts: messages.map((msg, i) => ({ msg, start: 0, count: rowCounts[i] ?? 0 })),
210
+ };
211
+ }
212
+ // Indicator rows depend on whether the window has hidden content on each
213
+ // side, which depends on view height — iterate until the chrome settles
214
+ // (0 → 1 → 2 indicators).
215
+ let above = 0;
216
+ let below = 0;
217
+ let winTop = 0;
218
+ let viewH = maxHeight;
219
+ for (let pass = 0; pass < 3; pass++) {
220
+ const chrome = (above > 0 ? 1 : 0) + (below > 0 ? 1 : 0);
221
+ viewH = Math.max(1, maxHeight - chrome);
222
+ const maxTop = Math.max(0, total - viewH);
223
+ winTop = top === null ? maxTop : Math.min(top, maxTop);
224
+ above = winTop;
225
+ below = Math.max(0, total - (winTop + viewH));
226
+ }
227
+ const winBottom = Math.min(total, winTop + viewH);
228
+ const parts = [];
229
+ let cursor = 0;
230
+ for (let i = 0; i < messages.length; i++) {
231
+ const len = rowCounts[i] ?? 0;
232
+ if (cursor < winBottom && cursor + len > winTop) {
233
+ const start = Math.max(0, winTop - cursor);
234
+ const count = Math.min(len - start, winBottom - cursor - start);
235
+ parts.push({ msg: messages[i], start, count });
236
+ }
237
+ cursor += len;
238
+ }
239
+ return { winTop, winBottom, above, below, parts };
240
+ }
4
241
  /**
5
- * Message list — renders all messages naturally.
6
- * Scrolling is handled by the terminal's own scrollback buffer.
242
+ * Message list — renders the tail of the conversation with row-level
243
+ * scrolling (PageUp/PageDown and mouse wheel) so trimmed content is never
244
+ * lost. Without `maxHeight` (terminal height unknown) messages flow
245
+ * naturally.
7
246
  */
8
- export function MessageList({ messages }) {
9
- return (_jsxs(Box, { flexDirection: "column", children: [messages.length === 0 && (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Text, { color: "gray", children: "\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\uFF0C\u8F93\u5165 /help \u67E5\u770B\u547D\u4EE4\uFF0CEsc \u53D6\u6D88\u8FD0\u884C" }) })), messages.map((msg) => (_jsx(MessageRow, { message: msg }, msg.id)))] }));
247
+ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice, selectionMode, onExitSelectionMode, interactive = true, }) {
248
+ const [windowTop, setWindowTop] = useState(null);
249
+ const [expanded, setExpanded] = useState(new Set());
250
+ const [selection, setSelection] = useState(null);
251
+ const rowCounts = useMemo(() => messages.map((m) => messageRowCount(m, columns, expanded.has(m.id))), [messages, columns, expanded]);
252
+ const slice = useMemo(() => sliceWindow(messages, rowCounts, maxHeight ?? 0, windowTop), [messages, rowCounts, maxHeight, windowTop]);
253
+ // Mouse wheel needs the current window geometry in the raw stdin listener,
254
+ // which subscribes once per maxHeight change.
255
+ const sliceRef = useRef(slice);
256
+ sliceRef.current = slice;
257
+ // The area mirrors the rendered rows; the same listener also maps drag
258
+ // coordinates to text. Rebuilt every render so streaming/scroll never
259
+ // desync the highlight from what is on screen.
260
+ const area = useMemo(() => buildArea(slice, columns, expanded), [slice, columns, expanded]);
261
+ const areaRef = useRef(area.rows);
262
+ areaRef.current = area.rows;
263
+ const kindsRef = useRef(area.kinds);
264
+ kindsRef.current = area.kinds;
265
+ const partMetaRef = useRef(area.partMeta);
266
+ partMetaRef.current = area.partMeta;
267
+ const onCopyRef = useRef(onCopyNotice);
268
+ onCopyRef.current = onCopyNotice;
269
+ const selectionModeRef = useRef(Boolean(selectionMode));
270
+ selectionModeRef.current = Boolean(selectionMode);
271
+ const lastCellRef = useRef(null);
272
+ const clicks = useRef(createClickCounter());
273
+ // Terminal coordinates (1-based) → selection module coordinates: rows index
274
+ // into `area.rows`, columns start at terminal col 2 (paddingLeft=1).
275
+ const toCell = (p) => mouseToCell(area.rows, p.row, p.col);
276
+ // Per-part highlight ranges, aligned with each part's shown lines. Null
277
+ // while no selection is active so MessageRow keeps its memo hit.
278
+ const ranges = selection ? selectionRanges(area.rows, toCell(selection.anchor), toCell(selection.cur)) : null;
279
+ const partSelections = ranges ? alignPartSelections(ranges, area.partMeta) : null;
280
+ // Mouse release after a drag: turn the terminal-coordinate selection into
281
+ // the selected text and push it to the clipboard. The highlight stays until
282
+ // the next click/key/wheel.
283
+ const finalizeCopy = useCallback((sel) => {
284
+ const rows = areaRef.current;
285
+ const text = textFromSelection(rows, kindsRef.current, selectionRanges(rows, mouseToCell(rows, sel.anchor.row, sel.anchor.col), mouseToCell(rows, sel.cur.row, sel.cur.col)));
286
+ if (text === "")
287
+ onCopyRef.current?.("未选中文本");
288
+ else if (writeClipboard(text))
289
+ onCopyRef.current?.(`已复制 ${Array.from(text).length} 字`);
290
+ else
291
+ onCopyRef.current?.("复制失败");
292
+ }, []);
293
+ const finalizeRef = useRef(finalizeCopy);
294
+ finalizeRef.current = finalizeCopy;
295
+ const selectionRef = useRef(selection);
296
+ selectionRef.current = selection;
297
+ const onExitRef = useRef(onExitSelectionMode);
298
+ onExitRef.current = onExitSelectionMode;
299
+ const seedSelection = () => {
300
+ const current = selectionRef.current;
301
+ if (current)
302
+ return current;
303
+ if (lastCellRef.current)
304
+ return { anchor: lastCellRef.current, cur: lastCellRef.current };
305
+ const rows = areaRef.current;
306
+ const i = rows.findIndex((r) => r != null);
307
+ const cell = i >= 0 ? { row: i + 1, col: 2 } : { row: 1, col: 2 };
308
+ return { anchor: cell, cur: cell };
309
+ };
310
+ const clampSelection = (sel) => {
311
+ const n = Math.max(1, areaRef.current.length);
312
+ return {
313
+ anchor: sel.anchor,
314
+ cur: { row: Math.max(1, Math.min(n, sel.cur.row)), col: Math.max(1, sel.cur.col) },
315
+ };
316
+ };
317
+ const nudgeSelection = (drow, dcol) => {
318
+ const s = seedSelection();
319
+ const n = Math.max(1, areaRef.current.length);
320
+ let row = Math.max(1, Math.min(n, s.cur.row + drow));
321
+ if (drow !== 0) {
322
+ while (row >= 1 && row <= n && areaRef.current[row - 1] == null) {
323
+ const next = row + Math.sign(drow);
324
+ if (next < 1 || next > n)
325
+ break;
326
+ row = next;
327
+ }
328
+ }
329
+ const line = areaRef.current[row - 1];
330
+ const width = line ? displayWidth(stripAnsi(line)) : 0;
331
+ const col = Math.max(1, Math.min(2 + width, s.cur.col + dcol));
332
+ setSelection({ anchor: s.anchor, cur: { row, col } });
333
+ };
334
+ useEffect(() => {
335
+ if (!selectionMode)
336
+ return;
337
+ setSelection((prev) => prev ?? seedSelection());
338
+ }, [selectionMode, seedSelection]);
339
+ const toggleExpanded = (id) => {
340
+ setExpanded((prev) => {
341
+ const next = new Set(prev);
342
+ if (next.has(id))
343
+ next.delete(id);
344
+ else
345
+ next.add(id);
346
+ return next;
347
+ });
348
+ };
349
+ const toggleRef = useRef(toggleExpanded);
350
+ toggleRef.current = toggleExpanded;
351
+ const scrollUp = (step) => {
352
+ setWindowTop((t) => {
353
+ const cur = t ?? sliceRef.current.winTop;
354
+ return Math.max(0, cur - step);
355
+ });
356
+ };
357
+ const scrollDown = (step) => {
358
+ setWindowTop((t) => {
359
+ if (t === null)
360
+ return t;
361
+ const maxTop = sliceRef.current.winTop + sliceRef.current.below;
362
+ return t + step >= maxTop ? null : t + step;
363
+ });
364
+ };
365
+ const drag = useRef(createDragMachine());
366
+ useInput((_input, key) => {
367
+ if (selectionModeRef.current) {
368
+ const inkKey = key;
369
+ if (key.escape) {
370
+ setSelection(null);
371
+ onExitRef.current?.();
372
+ return;
373
+ }
374
+ if (key.return) {
375
+ const sel = selectionRef.current ?? seedSelection();
376
+ finalizeRef.current(sel);
377
+ onExitRef.current?.();
378
+ return;
379
+ }
380
+ if (key.leftArrow) {
381
+ nudgeSelection(0, -1);
382
+ return;
383
+ }
384
+ if (key.rightArrow) {
385
+ nudgeSelection(0, 1);
386
+ return;
387
+ }
388
+ if (key.upArrow) {
389
+ nudgeSelection(-1, 0);
390
+ return;
391
+ }
392
+ if (key.downArrow) {
393
+ nudgeSelection(1, 0);
394
+ return;
395
+ }
396
+ if (inkKey.home) {
397
+ const s = seedSelection();
398
+ setSelection({ anchor: s.anchor, cur: { row: s.cur.row, col: 1 } });
399
+ return;
400
+ }
401
+ if (inkKey.end) {
402
+ const s = seedSelection();
403
+ const line = areaRef.current[s.cur.row - 1];
404
+ const width = line ? displayWidth(stripAnsi(line)) : 0;
405
+ setSelection({ anchor: s.anchor, cur: { row: s.cur.row, col: 2 + width } });
406
+ return;
407
+ }
408
+ if (key.pageUp) {
409
+ scrollUp(slice.winBottom - slice.winTop);
410
+ setSelection((prev) => (prev ? clampSelection(prev) : prev));
411
+ }
412
+ else if (key.pageDown) {
413
+ scrollDown(slice.winBottom - slice.winTop);
414
+ setSelection((prev) => (prev ? clampSelection(prev) : prev));
415
+ }
416
+ return;
417
+ }
418
+ if (!_input.startsWith("[<") && !/^[\d;,]+[Mm]$/.test(_input)) {
419
+ clearDrag(drag.current);
420
+ setSelection(null);
421
+ }
422
+ if (key.pageUp) {
423
+ const step = slice.winBottom - slice.winTop;
424
+ scrollUp(step);
425
+ }
426
+ else if (key.pageDown) {
427
+ const step = slice.winBottom - slice.winTop;
428
+ scrollDown(step);
429
+ }
430
+ if ((_input === " " || _input === "o") && !key.ctrl && !key.meta) {
431
+ const center = Math.floor((slice.winTop + slice.winBottom) / 2);
432
+ let cursor = 0;
433
+ for (let i = 0; i < messages.length; i++) {
434
+ const len = rowCounts[i] ?? 0;
435
+ if (center >= cursor && center < cursor + len && messages[i].role === "tool") {
436
+ toggleRef.current(messages[i].id);
437
+ break;
438
+ }
439
+ cursor += len;
440
+ }
441
+ }
442
+ }, { isActive: Boolean(interactive && maxHeight && maxHeight > 0) });
443
+ // Scrolling back to the bottom when content shrinks (e.g. /new, /undo)
444
+ // so the view never hangs at a stale offset.
445
+ const prevMessageCount = useRef(messages.length);
446
+ useEffect(() => {
447
+ if (messages.length < prevMessageCount.current)
448
+ setWindowTop(null);
449
+ prevMessageCount.current = messages.length;
450
+ }, [messages]);
451
+ // Map a terminal row (1-based) to the visible message row it lands on and
452
+ // toggle its expansion. Only tool rows are clickable; the top indicator row
453
+ // and anything below the window are ignored.
454
+ const handleClick = (row) => {
455
+ const { above, parts } = sliceRef.current;
456
+ let offset = row - 1;
457
+ if (offset < 0)
458
+ return;
459
+ if (above > 0) {
460
+ offset -= 1;
461
+ if (offset < 0)
462
+ return;
463
+ }
464
+ for (const part of parts) {
465
+ if (offset < part.count) {
466
+ if (part.msg.role === "tool")
467
+ toggleRef.current(part.msg.id);
468
+ return;
469
+ }
470
+ offset -= part.count;
471
+ }
472
+ };
473
+ const selectWord = (row, col) => {
474
+ const rows = areaRef.current;
475
+ const cell = mouseToCell(rows, row, col);
476
+ const line = rows[cell.row];
477
+ if (!line)
478
+ return;
479
+ const range = wordRangeAt(stripAnsi(line), cell.col);
480
+ if (range.end <= range.start)
481
+ return;
482
+ const sel = {
483
+ anchor: { row: cell.row + 1, col: range.start + 2 },
484
+ cur: { row: cell.row + 1, col: range.end + 1 },
485
+ };
486
+ setSelection(sel);
487
+ finalizeRef.current(sel);
488
+ };
489
+ const selectMessage = (row) => {
490
+ const rowIndex = row - 1;
491
+ const meta = partMetaRef.current[rowIndex];
492
+ if (!meta)
493
+ return;
494
+ let start = rowIndex;
495
+ let end = rowIndex;
496
+ while (start > 0 && partMetaRef.current[start - 1]?.part === meta.part)
497
+ start--;
498
+ while (end < partMetaRef.current.length - 1 && partMetaRef.current[end + 1]?.part === meta.part)
499
+ end++;
500
+ const lastLine = areaRef.current[end];
501
+ const width = lastLine ? displayWidth(stripAnsi(lastLine)) : 0;
502
+ const sel = {
503
+ anchor: { row: start + 1, col: 2 },
504
+ cur: { row: end + 1, col: Math.max(2, 1 + width) },
505
+ };
506
+ setSelection(sel);
507
+ finalizeRef.current(sel);
508
+ };
509
+ // Mouse handling in the message area: drag with the left button selects text
510
+ // and auto-copies on release; a click (press+release without motion) still
511
+ // toggles tool rows; the wheel scrolls. The drag state machine (drag-state)
512
+ // is fed every non-wheel SGR event; presses are accepted anywhere inside the
513
+ // allocated message region (maxHeight), so a drag that starts on the blank
514
+ // gap below a short conversation still works. A drag/release may land
515
+ // outside the region, in which case the coordinates are clamped by the
516
+ // selection math.
517
+ const { stdin } = useStdin();
518
+ const wheelBuffer = useRef("");
519
+ useEffect(() => {
520
+ if (!stdin || !maxHeight || maxHeight <= 0)
521
+ return;
522
+ const handleData = (data) => {
523
+ wheelBuffer.current += data.toString("utf-8");
524
+ let scanned;
525
+ while ((scanned = scanSgrMouse(wheelBuffer.current))) {
526
+ wheelBuffer.current = scanned.rest;
527
+ const { button, row } = scanned.event;
528
+ const areaHeight = areaRef.current.length;
529
+ // Wheel (base 64/65, or with the motion flag while a button is held).
530
+ if (button === WHEEL_UP ||
531
+ button === WHEEL_DOWN ||
532
+ button === WHEEL_UP + MOTION ||
533
+ button === WHEEL_DOWN + MOTION) {
534
+ if (row >= 1 && row <= areaHeight + 2) {
535
+ clearDrag(drag.current);
536
+ if (!selectionModeRef.current)
537
+ setSelection(null);
538
+ if (button === WHEEL_UP || button === WHEEL_UP + MOTION)
539
+ scrollUp(3);
540
+ else
541
+ scrollDown(3);
542
+ if (selectionModeRef.current)
543
+ setSelection((prev) => (prev ? clampSelection(prev) : prev));
544
+ }
545
+ continue;
546
+ }
547
+ const outcome = stepDrag(drag.current, scanned.event, maxHeight);
548
+ if (outcome?.type === "selection")
549
+ setSelection(outcome.selection);
550
+ else if (outcome?.type === "copy")
551
+ finalizeRef.current(outcome.selection);
552
+ else if (outcome?.type === "click") {
553
+ lastCellRef.current = { row: outcome.row, col: outcome.col };
554
+ const count = noteClick(clicks.current, { row: outcome.row, col: outcome.col, at: Date.now() });
555
+ if (count >= 3)
556
+ selectMessage(outcome.row);
557
+ else if (count === 2)
558
+ selectWord(outcome.row, outcome.col);
559
+ else
560
+ handleClick(outcome.row);
561
+ }
562
+ }
563
+ if (wheelBuffer.current.length > 64) {
564
+ const esc = wheelBuffer.current.lastIndexOf("\x1b");
565
+ wheelBuffer.current = esc === -1 ? "" : wheelBuffer.current.slice(esc);
566
+ }
567
+ };
568
+ stdin.on("data", handleData);
569
+ return () => {
570
+ stdin.off("data", handleData);
571
+ };
572
+ }, [stdin, maxHeight, clampSelection, handleClick, scrollDown, scrollUp, selectMessage, selectWord]);
573
+ if (!maxHeight || maxHeight <= 0) {
574
+ return (_jsxs(Box, { flexDirection: "column", children: [messages.length === 0 && (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Text, { color: "gray", children: "\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\uFF0C\u8F93\u5165 /help \u67E5\u770B\u547D\u4EE4\uFF0CEsc \u53D6\u6D88\u8FD0\u884C" }) })), messages.map((msg) => (_jsx(MessageRow, { message: msg, lines: cachedMessageLines(msg, columns, expanded.has(msg.id)) }, msg.id)))] }));
575
+ }
576
+ return (_jsxs(Box, { flexDirection: "column", height: maxHeight, overflow: "hidden", width: "100%", children: [messages.length === 0 && (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Text, { color: "gray", children: "\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\uFF0C\u8F93\u5165 /help \u67E5\u770B\u547D\u4EE4\uFF0CEsc \u53D6\u6D88\u8FD0\u884C" }) })), slice.above > 0 && (_jsx(Box, { paddingLeft: 1, flexShrink: 0, children: _jsxs(Text, { color: "gray", dimColor: true, children: ["\u2026 \u4EE5\u4E0A ", slice.above, " \u884C"] }) })), slice.parts.map(({ msg, start, count }, p) => (_jsx(MessageRow, { message: msg, lines: cachedMessageLines(msg, columns, expanded.has(msg.id)), start: start, count: count, selection: partSelections?.get(p) }, msg.id))), slice.below > 0 && (_jsx(Box, { paddingLeft: 1, flexShrink: 0, children: _jsx(Text, { color: "gray", dimColor: true, children: "\u2193 \u6309 PageDown \u56DE\u5230\u5E95\u90E8" }) }))] }));
577
+ }
578
+ /** Map a window slice (rows incl. margin) to renderable lines. */
579
+ function sliceLines(msg, lines, start, count = lines.length) {
580
+ if (msg.role !== "user")
581
+ return { lines: lines.slice(start, start + count), marginTop: false, lineOffset: start };
582
+ const margin = MARGIN;
583
+ if (start === 0) {
584
+ return { lines: lines.slice(0, Math.max(0, count - margin)), marginTop: true, lineOffset: 0 };
585
+ }
586
+ const contentStart = start - margin;
587
+ return { lines: lines.slice(contentStart, contentStart + count), marginTop: false, lineOffset: contentStart };
588
+ }
589
+ function copyKindFor(msg, lineIndex) {
590
+ if (msg.role === "user")
591
+ return lineIndex === 0 ? "user-head" : "plain";
592
+ if (msg.role === "tool")
593
+ return lineIndex === 0 ? "tool-head" : "tool-body";
594
+ return "plain";
10
595
  }
11
- /** Render markdown content to ANSI-formatted string */
12
- function renderMarkdown(content) {
13
- const md = new MarkdownRenderer();
14
- const output = md.write(content);
15
- const flushed = md.flush();
16
- return output + flushed;
596
+ /**
597
+ * Rebuild the exact rows the message list renders, one entry per terminal row:
598
+ * chrome/blank rows are null, message rows carry their ANSI line. `partMeta`
599
+ * maps each row back to (part index, shown-line index) so selection ranges can
600
+ * be handed to MessageRow aligned with its own `lines` slicing.
601
+ */
602
+ function buildArea(slice, columns, expanded) {
603
+ const rows = [];
604
+ const kinds = [];
605
+ const partMeta = [];
606
+ if (slice.above > 0) {
607
+ rows.push(null);
608
+ kinds.push("plain");
609
+ partMeta.push(null);
610
+ }
611
+ for (let p = 0; p < slice.parts.length; p++) {
612
+ const part = slice.parts[p];
613
+ const lines = cachedMessageLines(part.msg, columns, expanded.has(part.msg.id));
614
+ const { lines: shown, marginTop, lineOffset } = sliceLines(part.msg, lines, part.start, part.count);
615
+ if (marginTop) {
616
+ rows.push(null);
617
+ kinds.push("plain");
618
+ partMeta.push(null);
619
+ }
620
+ for (let i = 0; i < shown.length; i++) {
621
+ rows.push(shown[i]);
622
+ kinds.push(copyKindFor(part.msg, lineOffset + i));
623
+ partMeta.push({ part: p, shown: i });
624
+ }
625
+ }
626
+ if (slice.below > 0) {
627
+ rows.push(null);
628
+ kinds.push("plain");
629
+ partMeta.push(null);
630
+ }
631
+ return { rows, kinds, partMeta };
17
632
  }
18
- function MessageRow({ message }) {
633
+ /** Message row memoized so streaming updates to the last assistant message
634
+ * don't re-render every row. `selection` (aligned with the shown lines) adds
635
+ * inverse-video highlights; it is undefined when no drag is active, keeping
636
+ * the memo hit during streaming. */
637
+ const MessageRow = React.memo(function MessageRow({ message, lines, start = 0, count, selection, }) {
638
+ const { lines: shown, marginTop } = sliceLines(message, lines, start, count);
639
+ if (shown.length === 0) {
640
+ if (!marginTop)
641
+ return null;
642
+ return _jsx(Box, { height: 1, flexShrink: 0 });
643
+ }
644
+ const sel = selection;
645
+ const apply = (i, line) => {
646
+ const range = sel?.[i];
647
+ return range && range.end > range.start ? applySelection(line, range.start, range.end) : line;
648
+ };
649
+ const splitFirst = (prefix) => {
650
+ const range = sel?.[0];
651
+ if (!range)
652
+ return { head: null, tail: null };
653
+ return splitRangeAt(range, displayWidth(prefix));
654
+ };
655
+ const paintPrefix = (prefix, head) => head ? applySelection(prefix, head.start, head.end) : prefix;
656
+ const paintBody = (body, tail) => tail ? applySelection(body, tail.start, tail.end) : body;
19
657
  switch (message.role) {
20
- case "user":
21
- return (_jsxs(Box, { paddingLeft: 1, marginTop: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [">", " "] }), _jsx(Text, { children: message.content })] }));
658
+ case "user": {
659
+ const first = shown[0] ?? "";
660
+ const rest = shown.slice(1);
661
+ const prefix = visualPrefix(first, "user");
662
+ const split = prefix ? splitFirst(prefix) : null;
663
+ const headBody = prefix ? paintBody(first.slice(prefix.length), split.tail) : apply(0, first);
664
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, marginTop: marginTop ? 1 : 0, flexShrink: 0, children: [_jsxs(Box, { flexWrap: "nowrap", children: [prefix !== "" && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "cyan", bold: true, children: paintPrefix(prefix, split.head) }) })), _jsx(Text, { wrap: "truncate-end", children: headBody })] }), rest.map((l, k) => (_jsx(Text, { wrap: "truncate-end", children: apply(k + 1, l) }, k)))] }));
665
+ }
22
666
  case "assistant":
23
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: renderMarkdown(message.content) }) }));
667
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, flexShrink: 0, children: shown.map((l, i) => (_jsx(Text, { wrap: "truncate-end", children: apply(i, l) }, i))) }));
24
668
  case "thinking":
25
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { dimColor: true, children: message.content }) }));
26
- case "tool":
27
- return (_jsxs(Box, { paddingLeft: 1, children: [_jsxs(Text, { color: "yellow", children: ["\u26A1 ", message.toolName, " "] }), _jsx(Text, { color: "gray", children: message.content.length > 120 ? message.content.slice(0, 120) + "..." : message.content })] }));
669
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, flexShrink: 0, children: shown.map((l, i) => (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: apply(i, l) }, i))) }));
670
+ case "tool": {
671
+ const first = shown[0] ?? "";
672
+ const rest = shown.slice(1);
673
+ const prefix = visualPrefix(first, "tool");
674
+ const split = prefix ? splitFirst(prefix) : null;
675
+ const headBody = prefix ? paintBody(first.slice(prefix.length), split.tail) : apply(0, first);
676
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, flexShrink: 0, children: [_jsxs(Box, { flexWrap: "nowrap", children: [prefix !== "" && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "yellow", children: paintPrefix(prefix, split.head) }) })), _jsx(Text, { color: prefix ? (message.toolIsError ? "red" : "gray") : undefined, wrap: "truncate-end", children: headBody })] }), rest.map((l, k) => (_jsx(Text, { color: message.toolIsError ? "red" : undefined, wrap: "truncate-end", children: apply(k + 1, l) }, k)))] }));
677
+ }
28
678
  case "system":
29
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: message.content }) }));
679
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, flexShrink: 0, children: shown.map((l, i) => (_jsx(Text, { wrap: "truncate-end", children: apply(i, l) }, i))) }));
30
680
  default:
31
681
  return null;
32
682
  }
33
- }
683
+ }, (prev, next) => prev.message.id === next.message.id &&
684
+ prev.start === next.start &&
685
+ prev.count === next.count &&
686
+ prev.lines === next.lines &&
687
+ prev.selection === next.selection);