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