min-agent 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
@@ -2,10 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useMemo, useEffect, useRef, useState, useCallback } from "react";
3
3
  import { Box, Text, useInput, useStdin } from "ink";
4
4
  import { MarkdownRenderer } from "../markdown.js";
5
- import { wrapByWidth, charWidth, displayWidth } from "./text-width.js";
5
+ import { wrapByWidth, clustersOf, displayWidth } from "./text-width.js";
6
+ import { messageTextWidth } from "./layout.js";
6
7
  import { scanSgrMouse, WHEEL_UP, WHEEL_DOWN, MOTION } from "./mouse.js";
7
- import { applySelection, extractText, selectionRanges } from "./selection.js";
8
+ import { applySelection, selectionRanges, stripAnsi, textFromSelection, wordRangeAt, mouseToCell, splitRangeAt, alignPartSelections, visualPrefix, } from "./selection.js";
8
9
  import { createDragMachine, clearDrag, stepDrag } from "./drag-state.js";
10
+ import { createClickCounter, noteClick } from "./click-count.js";
9
11
  import { writeClipboard } from "../clipboard.js";
10
12
  const RESET = "\x1b[0m";
11
13
  /** Split a single ANSI-formatted line into physical rows of at most `maxWidth`
@@ -25,15 +27,14 @@ function splitAnsiRows(line, maxWidth) {
25
27
  current += token;
26
28
  continue;
27
29
  }
28
- for (const ch of token) {
29
- const w = charWidth(ch);
30
+ for (const { text: g, width: w } of clustersOf(token)) {
30
31
  if (width > 0 && width + w > maxWidth) {
31
32
  rows.push(current + RESET);
32
- current = prefix + ch;
33
+ current = prefix + g;
33
34
  width = w;
34
35
  }
35
36
  else {
36
- current += ch;
37
+ current += g;
37
38
  width += w;
38
39
  }
39
40
  }
@@ -45,11 +46,10 @@ function splitAnsiRows(line, maxWidth) {
45
46
  function clipToWidth(text, maxWidth) {
46
47
  let shown = "";
47
48
  let width = 0;
48
- for (const ch of text) {
49
- const w = charWidth(ch);
49
+ for (const { text: g, width: w } of clustersOf(text)) {
50
50
  if (width + w > maxWidth)
51
51
  break;
52
- shown += ch;
52
+ shown += g;
53
53
  width += w;
54
54
  }
55
55
  return shown;
@@ -74,34 +74,37 @@ function resultPreviewLines(result) {
74
74
  * default; clicking the row (see MessageList) toggles `expanded` to reveal the
75
75
  * full tool result. */
76
76
  export function messageLines(msg, columns, expanded) {
77
- const textWidth = Math.max(1, columns - 1); // Box paddingLeft
77
+ const textWidth = messageTextWidth(columns);
78
78
  switch (msg.role) {
79
79
  case "user":
80
80
  return wrapByWidth(`> ${msg.content}`, textWidth);
81
81
  case "assistant": {
82
- const md = new MarkdownRenderer();
83
- const rendered = md.write(msg.content) + md.flush();
84
- const raw = rendered.replace(/\n$/, "");
85
- if (raw === "")
86
- return [];
87
- const lines = raw.split("\n");
88
- // Drop leading/trailing blank lines (models often pad around tool calls)
89
- // so a whitespace-only or padded message doesn't leave empty rows.
90
- let start = 0;
91
- let end = lines.length;
92
- while (start < end && lines[start].trim() === "")
93
- start++;
94
- while (end > start && lines[end - 1].trim() === "")
95
- end--;
96
- if (start === end)
97
- return [];
98
- const rows = [];
99
- for (let i = start; i < end; i++)
100
- rows.push(...splitAnsiRows(lines[i], textWidth));
101
- return rows;
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
+ }
102
105
  }
103
106
  case "thinking":
104
- return msg.content && msg.content.trim() ? wrapByWidth(msg.content, textWidth) : [];
107
+ return msg.content?.trim() ? wrapByWidth(msg.content, textWidth) : [];
105
108
  case "tool": {
106
109
  const name = msg.toolName ?? "";
107
110
  // The call summary is a single logical line; collapse any stray newlines
@@ -111,14 +114,18 @@ export function messageLines(msg, columns, expanded) {
111
114
  const contentWidth = Math.max(1, textWidth - displayWidth(label));
112
115
  if (!expanded) {
113
116
  const tail = msg.toolResultSummary ? ` → ${msg.toolResultSummary}` : "";
114
- if (displayWidth(call + tail) <= contentWidth)
115
- return [`${label}${call}${tail}`];
116
- // The result summary is the more informative half, so the arguments
117
- // give way first.
118
- const room = Math.max(1, contentWidth - displayWidth(tail) - 1);
119
- return [`${label}${clipToWidth(call, room)}…${tail}`];
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)];
120
121
  }
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
+ }
122
129
  const result = msg.toolResult;
123
130
  if (result && result.trim() !== "") {
124
131
  for (const line of resultPreviewLines(result)) {
@@ -158,6 +165,7 @@ function cacheKey(msg, columns, expanded) {
158
165
  expanded ? "1" : "0",
159
166
  msg.toolName ?? "",
160
167
  msg.toolResultSummary ?? "",
168
+ (msg.toolChildren ?? []).map((c) => `${c.name}:${c.summary}`).join("|"),
161
169
  ].join("\u0000");
162
170
  }
163
171
  function cachedMessageLines(msg, columns, expanded) {
@@ -179,11 +187,16 @@ const MARGIN = 1;
179
187
  function messageRowCount(msg, columns, expanded) {
180
188
  return cachedMessageLines(msg, columns, expanded).length + (msg.role === "user" ? MARGIN : 0);
181
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
+ }
182
194
  /**
183
195
  * Compute the visible window of a message list for `maxHeight` rows, given
184
196
  * the physical row count of each message and an optional scroll offset
185
- * (`null` = stick to the bottom). One row is reserved for the hidden
186
- * indicator when either side has hidden content.
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`.
187
200
  */
188
201
  export function sliceWindow(messages, rowCounts, maxHeight, top) {
189
202
  const total = rowCounts.reduce((a, b) => a + b, 0);
@@ -196,13 +209,16 @@ export function sliceWindow(messages, rowCounts, maxHeight, top) {
196
209
  parts: messages.map((msg, i) => ({ msg, start: 0, count: rowCounts[i] ?? 0 })),
197
210
  };
198
211
  }
199
- // The indicator row feeds back into the view height; two passes settle.
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).
200
215
  let above = 0;
201
216
  let below = 0;
202
217
  let winTop = 0;
203
218
  let viewH = maxHeight;
204
- for (let pass = 0; pass < 2; pass++) {
205
- viewH = maxHeight - (above > 0 || below > 0 ? 1 : 0);
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);
206
222
  const maxTop = Math.max(0, total - viewH);
207
223
  winTop = top === null ? maxTop : Math.min(top, maxTop);
208
224
  above = winTop;
@@ -228,7 +244,7 @@ export function sliceWindow(messages, rowCounts, maxHeight, top) {
228
244
  * lost. Without `maxHeight` (terminal height unknown) messages flow
229
245
  * naturally.
230
246
  */
231
- export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice }) {
247
+ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice, selectionMode, onExitSelectionMode, interactive = true, }) {
232
248
  const [windowTop, setWindowTop] = useState(null);
233
249
  const [expanded, setExpanded] = useState(new Set());
234
250
  const [selection, setSelection] = useState(null);
@@ -244,47 +260,29 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
244
260
  const area = useMemo(() => buildArea(slice, columns, expanded), [slice, columns, expanded]);
245
261
  const areaRef = useRef(area.rows);
246
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;
247
267
  const onCopyRef = useRef(onCopyNotice);
248
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());
249
273
  // Terminal coordinates (1-based) → selection module coordinates: rows index
250
274
  // into `area.rows`, columns start at terminal col 2 (paddingLeft=1).
251
- const toCell = (p) => ({ row: p.row - 1, col: p.col - 2 });
275
+ const toCell = (p) => mouseToCell(area.rows, p.row, p.col);
252
276
  // Per-part highlight ranges, aligned with each part's shown lines. Null
253
277
  // while no selection is active so MessageRow keeps its memo hit.
254
278
  const ranges = selection ? selectionRanges(area.rows, toCell(selection.anchor), toCell(selection.cur)) : null;
255
- const partSelections = (() => {
256
- if (!ranges)
257
- return null;
258
- const map = new Map();
259
- for (let i = 0; i < ranges.length; i++) {
260
- const meta = area.partMeta[i];
261
- const range = ranges[i];
262
- if (!meta || !range)
263
- continue;
264
- let arr = map.get(meta.part);
265
- if (!arr) {
266
- arr = [];
267
- map.set(meta.part, arr);
268
- }
269
- arr[meta.shown] = range;
270
- }
271
- return map;
272
- })();
279
+ const partSelections = ranges ? alignPartSelections(ranges, area.partMeta) : null;
273
280
  // Mouse release after a drag: turn the terminal-coordinate selection into
274
281
  // the selected text and push it to the clipboard. The highlight stays until
275
282
  // the next click/key/wheel.
276
283
  const finalizeCopy = useCallback((sel) => {
277
284
  const rows = areaRef.current;
278
- const ranges = selectionRanges(rows, toCell(sel.anchor), toCell(sel.cur));
279
- const parts = [];
280
- for (let i = 0; i < ranges.length; i++) {
281
- const range = ranges[i];
282
- if (range && rows[i])
283
- parts.push(extractText(rows[i], range.start, range.end));
284
- }
285
- while (parts.length > 0 && parts[parts.length - 1] === "")
286
- parts.pop();
287
- const text = parts.join("\n");
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)));
288
286
  if (text === "")
289
287
  onCopyRef.current?.("未选中文本");
290
288
  else if (writeClipboard(text))
@@ -294,6 +292,50 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
294
292
  }, []);
295
293
  const finalizeRef = useRef(finalizeCopy);
296
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]);
297
339
  const toggleExpanded = (id) => {
298
340
  setExpanded((prev) => {
299
341
  const next = new Set(prev);
@@ -322,14 +364,61 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
322
364
  };
323
365
  const drag = useRef(createDragMachine());
324
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
+ }
325
418
  if (!_input.startsWith("[<") && !/^[\d;,]+[Mm]$/.test(_input)) {
326
419
  clearDrag(drag.current);
327
420
  setSelection(null);
328
421
  }
329
- // Enter on a tool row toggles expansion (keyboard alternative to click)
330
- if (key.return && !_input) {
331
- // No-op: tool toggle is click-only; keep Enter for app-level shortcuts
332
- }
333
422
  if (key.pageUp) {
334
423
  const step = slice.winBottom - slice.winTop;
335
424
  scrollUp(step);
@@ -338,7 +427,6 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
338
427
  const step = slice.winBottom - slice.winTop;
339
428
  scrollDown(step);
340
429
  }
341
- // Keyboard expand/collapse: when a tool message is at window center, toggle it
342
430
  if ((_input === " " || _input === "o") && !key.ctrl && !key.meta) {
343
431
  const center = Math.floor((slice.winTop + slice.winBottom) / 2);
344
432
  let cursor = 0;
@@ -351,8 +439,8 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
351
439
  cursor += len;
352
440
  }
353
441
  }
354
- }, { isActive: Boolean(maxHeight && maxHeight > 0) });
355
- // Scrolling back to the bottom when content shrinks (e.g. /clear, /undo)
442
+ }, { isActive: Boolean(interactive && maxHeight && maxHeight > 0) });
443
+ // Scrolling back to the bottom when content shrinks (e.g. /new, /undo)
356
444
  // so the view never hangs at a stale offset.
357
445
  const prevMessageCount = useRef(messages.length);
358
446
  useEffect(() => {
@@ -382,6 +470,42 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
382
470
  offset -= part.count;
383
471
  }
384
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
+ };
385
509
  // Mouse handling in the message area: drag with the left button selects text
386
510
  // and auto-copies on release; a click (press+release without motion) still
387
511
  // toggles tool rows; the wheel scrolls. The drag state machine (drag-state)
@@ -400,17 +524,23 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
400
524
  let scanned;
401
525
  while ((scanned = scanSgrMouse(wheelBuffer.current))) {
402
526
  wheelBuffer.current = scanned.rest;
403
- const { button, row, col } = scanned.event;
527
+ const { button, row } = scanned.event;
404
528
  const areaHeight = areaRef.current.length;
405
529
  // Wheel (base 64/65, or with the motion flag while a button is held).
406
- if (button === WHEEL_UP || button === WHEEL_DOWN || button === WHEEL_UP + MOTION || button === WHEEL_DOWN + MOTION) {
530
+ if (button === WHEEL_UP ||
531
+ button === WHEEL_DOWN ||
532
+ button === WHEEL_UP + MOTION ||
533
+ button === WHEEL_DOWN + MOTION) {
407
534
  if (row >= 1 && row <= areaHeight + 2) {
408
535
  clearDrag(drag.current);
409
- setSelection(null);
536
+ if (!selectionModeRef.current)
537
+ setSelection(null);
410
538
  if (button === WHEEL_UP || button === WHEEL_UP + MOTION)
411
539
  scrollUp(3);
412
540
  else
413
541
  scrollDown(3);
542
+ if (selectionModeRef.current)
543
+ setSelection((prev) => (prev ? clampSelection(prev) : prev));
414
544
  }
415
545
  continue;
416
546
  }
@@ -419,8 +549,16 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
419
549
  setSelection(outcome.selection);
420
550
  else if (outcome?.type === "copy")
421
551
  finalizeRef.current(outcome.selection);
422
- else if (outcome?.type === "click")
423
- handleClick(outcome.row);
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
+ }
424
562
  }
425
563
  if (wheelBuffer.current.length > 64) {
426
564
  const esc = wheelBuffer.current.lastIndexOf("\x1b");
@@ -428,24 +566,32 @@ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice })
428
566
  }
429
567
  };
430
568
  stdin.on("data", handleData);
431
- return () => { stdin.off("data", handleData); };
432
- }, [stdin, maxHeight]);
569
+ return () => {
570
+ stdin.off("data", handleData);
571
+ };
572
+ }, [stdin, maxHeight, clampSelection, handleClick, scrollDown, scrollUp, selectMessage, selectWord]);
433
573
  if (!maxHeight || maxHeight <= 0) {
434
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)))] }));
435
575
  }
436
- 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" }) })), slice.above > 0 && (_jsx(Box, { paddingLeft: 1, 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, children: _jsx(Text, { color: "gray", dimColor: true, children: "\u2193 \u6309 PageDown \u56DE\u5230\u5E95\u90E8" }) }))] }));
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" }) }))] }));
437
577
  }
438
578
  /** Map a window slice (rows incl. margin) to renderable lines. */
439
579
  function sliceLines(msg, lines, start, count = lines.length) {
440
580
  if (msg.role !== "user")
441
- return { lines: lines.slice(start, start + count), marginTop: false };
581
+ return { lines: lines.slice(start, start + count), marginTop: false, lineOffset: start };
442
582
  const margin = MARGIN;
443
583
  if (start === 0) {
444
- // The window includes the margin row; content rows are one fewer.
445
- return { lines: lines.slice(0, Math.max(0, count - margin)), marginTop: true };
584
+ return { lines: lines.slice(0, Math.max(0, count - margin)), marginTop: true, lineOffset: 0 };
446
585
  }
447
586
  const contentStart = start - margin;
448
- return { lines: lines.slice(contentStart, contentStart + count), marginTop: false };
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";
449
595
  }
450
596
  /**
451
597
  * Rebuild the exact rows the message list renders, one entry per terminal row:
@@ -455,29 +601,34 @@ function sliceLines(msg, lines, start, count = lines.length) {
455
601
  */
456
602
  function buildArea(slice, columns, expanded) {
457
603
  const rows = [];
604
+ const kinds = [];
458
605
  const partMeta = [];
459
606
  if (slice.above > 0) {
460
607
  rows.push(null);
608
+ kinds.push("plain");
461
609
  partMeta.push(null);
462
610
  }
463
611
  for (let p = 0; p < slice.parts.length; p++) {
464
612
  const part = slice.parts[p];
465
613
  const lines = cachedMessageLines(part.msg, columns, expanded.has(part.msg.id));
466
- const { lines: shown, marginTop } = sliceLines(part.msg, lines, part.start, part.count);
614
+ const { lines: shown, marginTop, lineOffset } = sliceLines(part.msg, lines, part.start, part.count);
467
615
  if (marginTop) {
468
616
  rows.push(null);
617
+ kinds.push("plain");
469
618
  partMeta.push(null);
470
619
  }
471
620
  for (let i = 0; i < shown.length; i++) {
472
621
  rows.push(shown[i]);
622
+ kinds.push(copyKindFor(part.msg, lineOffset + i));
473
623
  partMeta.push({ part: p, shown: i });
474
624
  }
475
625
  }
476
626
  if (slice.below > 0) {
477
627
  rows.push(null);
628
+ kinds.push("plain");
478
629
  partMeta.push(null);
479
630
  }
480
- return { rows, partMeta };
631
+ return { rows, kinds, partMeta };
481
632
  }
482
633
  /** Message row — memoized so streaming updates to the last assistant message
483
634
  * don't re-render every row. `selection` (aligned with the shown lines) adds
@@ -485,57 +636,47 @@ function buildArea(slice, columns, expanded) {
485
636
  * the memo hit during streaming. */
486
637
  const MessageRow = React.memo(function MessageRow({ message, lines, start = 0, count, selection, }) {
487
638
  const { lines: shown, marginTop } = sliceLines(message, lines, start, count);
488
- if (shown.length === 0 && !marginTop)
489
- return null;
490
- const sel = selection && selection.length === shown.length ? selection : undefined;
639
+ if (shown.length === 0) {
640
+ if (!marginTop)
641
+ return null;
642
+ return _jsx(Box, { height: 1, flexShrink: 0 });
643
+ }
644
+ const sel = selection;
491
645
  const apply = (i, line) => {
492
646
  const range = sel?.[i];
493
647
  return range && range.end > range.start ? applySelection(line, range.start, range.end) : line;
494
648
  };
495
- // Selection on a decorated first line must be split: the prefix ("> " or
496
- // "⚡ name ▸ ") is rendered as its own Text before the content slice.
497
- const splitFirst = (prefixLen) => {
649
+ const splitFirst = (prefix) => {
498
650
  const range = sel?.[0];
499
651
  if (!range)
500
- return { prefix: null, content: null };
501
- const headRange = range.start < prefixLen && range.end > 0
502
- ? { start: range.start, end: Math.min(prefixLen, range.end) }
503
- : null;
504
- const contentRange = range.end > prefixLen
505
- ? { start: Math.max(0, range.start - prefixLen), end: range.end - prefixLen }
506
- : null;
507
- return { prefix: headRange, content: contentRange };
652
+ return { head: null, tail: null };
653
+ return splitRangeAt(range, displayWidth(prefix));
508
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;
509
657
  switch (message.role) {
510
658
  case "user": {
511
659
  const first = shown[0] ?? "";
512
660
  const rest = shown.slice(1);
513
- const isHead = first.startsWith("> ");
514
- const split = isHead ? splitFirst(2) : null;
515
- return (_jsxs(Box, { paddingLeft: 1, marginTop: marginTop ? 1 : 0, children: [isHead && (_jsx(Text, { color: "cyan", bold: true, children: split.prefix ? applySelection("> ", split.prefix.start, split.prefix.end) : "> " })), _jsxs(Text, { children: [isHead
516
- ? split.content
517
- ? applySelection(first.slice(2), split.content.start, split.content.end)
518
- : first.slice(2)
519
- : apply(0, first), rest.length > 0 ? `\n${rest.map((l, k) => apply(k + 1, l)).join("\n")}` : ""] })] }));
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)))] }));
520
665
  }
521
666
  case "assistant":
522
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
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))) }));
523
668
  case "thinking":
524
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { dimColor: true, children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
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))) }));
525
670
  case "tool": {
526
671
  const first = shown[0] ?? "";
527
672
  const rest = shown.slice(1);
528
- const isHead = first.startsWith("⚡ ");
529
- const prefixLen = isHead ? first.indexOf(" ", 2) + 1 : 0;
530
- const split = isHead ? splitFirst(prefixLen) : null;
531
- return (_jsxs(Box, { paddingLeft: 1, children: [isHead && (_jsx(Text, { color: "yellow", children: split.prefix ? applySelection(first.slice(0, prefixLen), split.prefix.start, split.prefix.end) : first.slice(0, prefixLen) })), _jsxs(Text, { color: isHead ? (message.toolIsError ? "red" : "gray") : undefined, children: [isHead
532
- ? split.content
533
- ? applySelection(first.slice(prefixLen), split.content.start, split.content.end)
534
- : first.slice(prefixLen)
535
- : apply(0, first), rest.length > 0 ? `\n${rest.map((l, k) => apply(k + 1, l)).join("\n")}` : ""] })] }));
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)))] }));
536
677
  }
537
678
  case "system":
538
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
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))) }));
539
680
  default:
540
681
  return null;
541
682
  }