min-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
@@ -1,33 +1,546 @@
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, charWidth, displayWidth } from "./text-width.js";
6
+ import { scanSgrMouse, WHEEL_UP, WHEEL_DOWN, MOTION } from "./mouse.js";
7
+ import { applySelection, extractText, selectionRanges } from "./selection.js";
8
+ import { createDragMachine, clearDrag, stepDrag } from "./drag-state.js";
9
+ import { writeClipboard } from "../clipboard.js";
10
+ const RESET = "\x1b[0m";
11
+ /** Split a single ANSI-formatted line into physical rows of at most `maxWidth`
12
+ * cells. Each row re-applies the styles active at its start and ends with a
13
+ * reset, so a window sliced in the middle of a message keeps correct colors. */
14
+ function splitAnsiRows(line, maxWidth) {
15
+ const rows = [];
16
+ let current = "";
17
+ let width = 0;
18
+ let prefix = "";
19
+ for (const token of line.split(/(\x1b\[[0-9;]*m)/g)) {
20
+ if (token.startsWith("\x1b[")) {
21
+ if (token === RESET)
22
+ prefix = "";
23
+ else
24
+ prefix += token;
25
+ current += token;
26
+ continue;
27
+ }
28
+ for (const ch of token) {
29
+ const w = charWidth(ch);
30
+ if (width > 0 && width + w > maxWidth) {
31
+ rows.push(current + RESET);
32
+ current = prefix + ch;
33
+ width = w;
34
+ }
35
+ else {
36
+ current += ch;
37
+ width += w;
38
+ }
39
+ }
40
+ }
41
+ rows.push(current + RESET);
42
+ return rows;
43
+ }
44
+ /** Longest prefix of `text` that fits in `maxWidth` cells. */
45
+ function clipToWidth(text, maxWidth) {
46
+ let shown = "";
47
+ let width = 0;
48
+ for (const ch of text) {
49
+ const w = charWidth(ch);
50
+ if (width + w > maxWidth)
51
+ break;
52
+ shown += ch;
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;
4
59
  /**
5
- * Message list renders all messages naturally.
6
- * Scrolling is handled by the terminal's own scrollback buffer.
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.
7
62
  */
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)))] }));
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 = Math.max(1, columns - 1); // Box paddingLeft
78
+ switch (msg.role) {
79
+ case "user":
80
+ return wrapByWidth(`> ${msg.content}`, textWidth);
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;
102
+ }
103
+ case "thinking":
104
+ return msg.content && msg.content.trim() ? wrapByWidth(msg.content, textWidth) : [];
105
+ case "tool": {
106
+ const name = msg.toolName ?? "";
107
+ // The call summary is a single logical line; collapse any stray newlines
108
+ // so it cannot push the row layout around.
109
+ const call = msg.content.replace(/\s+/g, " ").trim();
110
+ const label = `⚡ ${name} ${expanded ? "▾" : "▸"} `;
111
+ const contentWidth = Math.max(1, textWidth - displayWidth(label));
112
+ if (!expanded) {
113
+ 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}`];
120
+ }
121
+ const rows = wrapByWidth(`${label}${call}`, textWidth);
122
+ const result = msg.toolResult;
123
+ if (result && result.trim() !== "") {
124
+ for (const line of resultPreviewLines(result)) {
125
+ rows.push(...wrapByWidth(` ${line}`, textWidth));
126
+ }
127
+ }
128
+ return rows;
129
+ }
130
+ default: {
131
+ if (!msg.content)
132
+ return [];
133
+ const rows = [];
134
+ for (const line of msg.content.split("\n"))
135
+ rows.push(...wrapByWidth(line, textWidth));
136
+ return rows;
137
+ }
138
+ }
139
+ }
140
+ // Streaming appends mutate the last assistant message every frame; keying on
141
+ // id + content means the cache stays valid for the exact content rendered.
142
+ const LINE_CACHE_LIMIT = 256;
143
+ const lineCache = new Map();
144
+ function hashContent(s) {
145
+ let h = 0;
146
+ for (let i = 0; i < s.length; i++)
147
+ h = (h * 31 + s.charCodeAt(i)) | 0;
148
+ return String(h);
10
149
  }
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;
150
+ function cacheKey(msg, columns, expanded) {
151
+ return [
152
+ msg.id,
153
+ String(msg.content.length),
154
+ hashContent(msg.content.slice(0, 64) + msg.content.slice(-32)),
155
+ String(msg.toolResult?.length ?? 0),
156
+ msg.toolResult ? hashContent(msg.toolResult.slice(0, 64)) : "",
157
+ String(columns),
158
+ expanded ? "1" : "0",
159
+ msg.toolName ?? "",
160
+ msg.toolResultSummary ?? "",
161
+ ].join("\u0000");
17
162
  }
18
- function MessageRow({ message }) {
163
+ function cachedMessageLines(msg, columns, expanded) {
164
+ const key = cacheKey(msg, columns, expanded);
165
+ const hit = lineCache.get(key);
166
+ if (hit !== undefined)
167
+ return hit;
168
+ const lines = messageLines(msg, columns, expanded);
169
+ if (lineCache.size >= LINE_CACHE_LIMIT) {
170
+ const first = lineCache.keys().next().value;
171
+ if (first !== undefined)
172
+ lineCache.delete(first);
173
+ }
174
+ lineCache.set(key, lines);
175
+ return lines;
176
+ }
177
+ /** User messages carry a 1-row margin above them. */
178
+ const MARGIN = 1;
179
+ function messageRowCount(msg, columns, expanded) {
180
+ return cachedMessageLines(msg, columns, expanded).length + (msg.role === "user" ? MARGIN : 0);
181
+ }
182
+ /**
183
+ * Compute the visible window of a message list for `maxHeight` rows, given
184
+ * 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.
187
+ */
188
+ export function sliceWindow(messages, rowCounts, maxHeight, top) {
189
+ const total = rowCounts.reduce((a, b) => a + b, 0);
190
+ if (maxHeight <= 0) {
191
+ return {
192
+ winTop: 0,
193
+ winBottom: total,
194
+ above: 0,
195
+ below: 0,
196
+ parts: messages.map((msg, i) => ({ msg, start: 0, count: rowCounts[i] ?? 0 })),
197
+ };
198
+ }
199
+ // The indicator row feeds back into the view height; two passes settle.
200
+ let above = 0;
201
+ let below = 0;
202
+ let winTop = 0;
203
+ let viewH = maxHeight;
204
+ for (let pass = 0; pass < 2; pass++) {
205
+ viewH = maxHeight - (above > 0 || below > 0 ? 1 : 0);
206
+ const maxTop = Math.max(0, total - viewH);
207
+ winTop = top === null ? maxTop : Math.min(top, maxTop);
208
+ above = winTop;
209
+ below = Math.max(0, total - (winTop + viewH));
210
+ }
211
+ const winBottom = Math.min(total, winTop + viewH);
212
+ const parts = [];
213
+ let cursor = 0;
214
+ for (let i = 0; i < messages.length; i++) {
215
+ const len = rowCounts[i] ?? 0;
216
+ if (cursor < winBottom && cursor + len > winTop) {
217
+ const start = Math.max(0, winTop - cursor);
218
+ const count = Math.min(len - start, winBottom - cursor - start);
219
+ parts.push({ msg: messages[i], start, count });
220
+ }
221
+ cursor += len;
222
+ }
223
+ return { winTop, winBottom, above, below, parts };
224
+ }
225
+ /**
226
+ * Message list — renders the tail of the conversation with row-level
227
+ * scrolling (PageUp/PageDown and mouse wheel) so trimmed content is never
228
+ * lost. Without `maxHeight` (terminal height unknown) messages flow
229
+ * naturally.
230
+ */
231
+ export function MessageList({ messages, maxHeight, columns = 80, onCopyNotice }) {
232
+ const [windowTop, setWindowTop] = useState(null);
233
+ const [expanded, setExpanded] = useState(new Set());
234
+ const [selection, setSelection] = useState(null);
235
+ const rowCounts = useMemo(() => messages.map((m) => messageRowCount(m, columns, expanded.has(m.id))), [messages, columns, expanded]);
236
+ const slice = useMemo(() => sliceWindow(messages, rowCounts, maxHeight ?? 0, windowTop), [messages, rowCounts, maxHeight, windowTop]);
237
+ // Mouse wheel needs the current window geometry in the raw stdin listener,
238
+ // which subscribes once per maxHeight change.
239
+ const sliceRef = useRef(slice);
240
+ sliceRef.current = slice;
241
+ // The area mirrors the rendered rows; the same listener also maps drag
242
+ // coordinates to text. Rebuilt every render so streaming/scroll never
243
+ // desync the highlight from what is on screen.
244
+ const area = useMemo(() => buildArea(slice, columns, expanded), [slice, columns, expanded]);
245
+ const areaRef = useRef(area.rows);
246
+ areaRef.current = area.rows;
247
+ const onCopyRef = useRef(onCopyNotice);
248
+ onCopyRef.current = onCopyNotice;
249
+ // Terminal coordinates (1-based) → selection module coordinates: rows index
250
+ // into `area.rows`, columns start at terminal col 2 (paddingLeft=1).
251
+ const toCell = (p) => ({ row: p.row - 1, col: p.col - 2 });
252
+ // Per-part highlight ranges, aligned with each part's shown lines. Null
253
+ // while no selection is active so MessageRow keeps its memo hit.
254
+ 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
+ })();
273
+ // Mouse release after a drag: turn the terminal-coordinate selection into
274
+ // the selected text and push it to the clipboard. The highlight stays until
275
+ // the next click/key/wheel.
276
+ const finalizeCopy = useCallback((sel) => {
277
+ 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");
288
+ if (text === "")
289
+ onCopyRef.current?.("未选中文本");
290
+ else if (writeClipboard(text))
291
+ onCopyRef.current?.(`已复制 ${Array.from(text).length} 字`);
292
+ else
293
+ onCopyRef.current?.("复制失败");
294
+ }, []);
295
+ const finalizeRef = useRef(finalizeCopy);
296
+ finalizeRef.current = finalizeCopy;
297
+ const toggleExpanded = (id) => {
298
+ setExpanded((prev) => {
299
+ const next = new Set(prev);
300
+ if (next.has(id))
301
+ next.delete(id);
302
+ else
303
+ next.add(id);
304
+ return next;
305
+ });
306
+ };
307
+ const toggleRef = useRef(toggleExpanded);
308
+ toggleRef.current = toggleExpanded;
309
+ const scrollUp = (step) => {
310
+ setWindowTop((t) => {
311
+ const cur = t ?? sliceRef.current.winTop;
312
+ return Math.max(0, cur - step);
313
+ });
314
+ };
315
+ const scrollDown = (step) => {
316
+ setWindowTop((t) => {
317
+ if (t === null)
318
+ return t;
319
+ const maxTop = sliceRef.current.winTop + sliceRef.current.below;
320
+ return t + step >= maxTop ? null : t + step;
321
+ });
322
+ };
323
+ const drag = useRef(createDragMachine());
324
+ useInput((_input, key) => {
325
+ if (!_input.startsWith("[<") && !/^[\d;,]+[Mm]$/.test(_input)) {
326
+ clearDrag(drag.current);
327
+ setSelection(null);
328
+ }
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
+ if (key.pageUp) {
334
+ const step = slice.winBottom - slice.winTop;
335
+ scrollUp(step);
336
+ }
337
+ else if (key.pageDown) {
338
+ const step = slice.winBottom - slice.winTop;
339
+ scrollDown(step);
340
+ }
341
+ // Keyboard expand/collapse: when a tool message is at window center, toggle it
342
+ if ((_input === " " || _input === "o") && !key.ctrl && !key.meta) {
343
+ const center = Math.floor((slice.winTop + slice.winBottom) / 2);
344
+ let cursor = 0;
345
+ for (let i = 0; i < messages.length; i++) {
346
+ const len = rowCounts[i] ?? 0;
347
+ if (center >= cursor && center < cursor + len && messages[i].role === "tool") {
348
+ toggleRef.current(messages[i].id);
349
+ break;
350
+ }
351
+ cursor += len;
352
+ }
353
+ }
354
+ }, { isActive: Boolean(maxHeight && maxHeight > 0) });
355
+ // Scrolling back to the bottom when content shrinks (e.g. /clear, /undo)
356
+ // so the view never hangs at a stale offset.
357
+ const prevMessageCount = useRef(messages.length);
358
+ useEffect(() => {
359
+ if (messages.length < prevMessageCount.current)
360
+ setWindowTop(null);
361
+ prevMessageCount.current = messages.length;
362
+ }, [messages]);
363
+ // Map a terminal row (1-based) to the visible message row it lands on and
364
+ // toggle its expansion. Only tool rows are clickable; the top indicator row
365
+ // and anything below the window are ignored.
366
+ const handleClick = (row) => {
367
+ const { above, parts } = sliceRef.current;
368
+ let offset = row - 1;
369
+ if (offset < 0)
370
+ return;
371
+ if (above > 0) {
372
+ offset -= 1;
373
+ if (offset < 0)
374
+ return;
375
+ }
376
+ for (const part of parts) {
377
+ if (offset < part.count) {
378
+ if (part.msg.role === "tool")
379
+ toggleRef.current(part.msg.id);
380
+ return;
381
+ }
382
+ offset -= part.count;
383
+ }
384
+ };
385
+ // Mouse handling in the message area: drag with the left button selects text
386
+ // and auto-copies on release; a click (press+release without motion) still
387
+ // toggles tool rows; the wheel scrolls. The drag state machine (drag-state)
388
+ // is fed every non-wheel SGR event; presses are accepted anywhere inside the
389
+ // allocated message region (maxHeight), so a drag that starts on the blank
390
+ // gap below a short conversation still works. A drag/release may land
391
+ // outside the region, in which case the coordinates are clamped by the
392
+ // selection math.
393
+ const { stdin } = useStdin();
394
+ const wheelBuffer = useRef("");
395
+ useEffect(() => {
396
+ if (!stdin || !maxHeight || maxHeight <= 0)
397
+ return;
398
+ const handleData = (data) => {
399
+ wheelBuffer.current += data.toString("utf-8");
400
+ let scanned;
401
+ while ((scanned = scanSgrMouse(wheelBuffer.current))) {
402
+ wheelBuffer.current = scanned.rest;
403
+ const { button, row, col } = scanned.event;
404
+ const areaHeight = areaRef.current.length;
405
+ // 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) {
407
+ if (row >= 1 && row <= areaHeight + 2) {
408
+ clearDrag(drag.current);
409
+ setSelection(null);
410
+ if (button === WHEEL_UP || button === WHEEL_UP + MOTION)
411
+ scrollUp(3);
412
+ else
413
+ scrollDown(3);
414
+ }
415
+ continue;
416
+ }
417
+ const outcome = stepDrag(drag.current, scanned.event, maxHeight);
418
+ if (outcome?.type === "selection")
419
+ setSelection(outcome.selection);
420
+ else if (outcome?.type === "copy")
421
+ finalizeRef.current(outcome.selection);
422
+ else if (outcome?.type === "click")
423
+ handleClick(outcome.row);
424
+ }
425
+ if (wheelBuffer.current.length > 64) {
426
+ const esc = wheelBuffer.current.lastIndexOf("\x1b");
427
+ wheelBuffer.current = esc === -1 ? "" : wheelBuffer.current.slice(esc);
428
+ }
429
+ };
430
+ stdin.on("data", handleData);
431
+ return () => { stdin.off("data", handleData); };
432
+ }, [stdin, maxHeight]);
433
+ if (!maxHeight || maxHeight <= 0) {
434
+ 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
+ }
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" }) }))] }));
437
+ }
438
+ /** Map a window slice (rows incl. margin) to renderable lines. */
439
+ function sliceLines(msg, lines, start, count = lines.length) {
440
+ if (msg.role !== "user")
441
+ return { lines: lines.slice(start, start + count), marginTop: false };
442
+ const margin = MARGIN;
443
+ 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 };
446
+ }
447
+ const contentStart = start - margin;
448
+ return { lines: lines.slice(contentStart, contentStart + count), marginTop: false };
449
+ }
450
+ /**
451
+ * Rebuild the exact rows the message list renders, one entry per terminal row:
452
+ * chrome/blank rows are null, message rows carry their ANSI line. `partMeta`
453
+ * maps each row back to (part index, shown-line index) so selection ranges can
454
+ * be handed to MessageRow aligned with its own `lines` slicing.
455
+ */
456
+ function buildArea(slice, columns, expanded) {
457
+ const rows = [];
458
+ const partMeta = [];
459
+ if (slice.above > 0) {
460
+ rows.push(null);
461
+ partMeta.push(null);
462
+ }
463
+ for (let p = 0; p < slice.parts.length; p++) {
464
+ const part = slice.parts[p];
465
+ const lines = cachedMessageLines(part.msg, columns, expanded.has(part.msg.id));
466
+ const { lines: shown, marginTop } = sliceLines(part.msg, lines, part.start, part.count);
467
+ if (marginTop) {
468
+ rows.push(null);
469
+ partMeta.push(null);
470
+ }
471
+ for (let i = 0; i < shown.length; i++) {
472
+ rows.push(shown[i]);
473
+ partMeta.push({ part: p, shown: i });
474
+ }
475
+ }
476
+ if (slice.below > 0) {
477
+ rows.push(null);
478
+ partMeta.push(null);
479
+ }
480
+ return { rows, partMeta };
481
+ }
482
+ /** Message row — memoized so streaming updates to the last assistant message
483
+ * don't re-render every row. `selection` (aligned with the shown lines) adds
484
+ * inverse-video highlights; it is undefined when no drag is active, keeping
485
+ * the memo hit during streaming. */
486
+ const MessageRow = React.memo(function MessageRow({ message, lines, start = 0, count, selection, }) {
487
+ 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;
491
+ const apply = (i, line) => {
492
+ const range = sel?.[i];
493
+ return range && range.end > range.start ? applySelection(line, range.start, range.end) : line;
494
+ };
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) => {
498
+ const range = sel?.[0];
499
+ 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 };
508
+ };
19
509
  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 })] }));
510
+ case "user": {
511
+ const first = shown[0] ?? "";
512
+ 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")}` : ""] })] }));
520
+ }
22
521
  case "assistant":
23
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: renderMarkdown(message.content) }) }));
522
+ return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
24
523
  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 })] }));
524
+ return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { dimColor: true, children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
525
+ case "tool": {
526
+ const first = shown[0] ?? "";
527
+ 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")}` : ""] })] }));
536
+ }
28
537
  case "system":
29
- return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: message.content }) }));
538
+ return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: shown.map((l, i) => apply(i, l)).join("\n") }) }));
30
539
  default:
31
540
  return null;
32
541
  }
33
- }
542
+ }, (prev, next) => prev.message.id === next.message.id &&
543
+ prev.start === next.start &&
544
+ prev.count === next.count &&
545
+ prev.lines === next.lines &&
546
+ prev.selection === next.selection);
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useInput, useStdout } from "ink";
4
+ import { loadConfig, getActiveProvider, fetchModels } from "../config.js";
5
+ import { theme } from "./theme.js";
6
+ /**
7
+ * Worst-case terminal rows the picker occupies (header/filter + status rows +
8
+ * list + footer + borders), used by App so the message area never overlaps it.
9
+ */
10
+ export function modelPickerRows(terminalRows) {
11
+ const maxListRows = Math.max(4, terminalRows - 11);
12
+ return Math.min(6 + maxListRows + 3, Math.max(6, terminalRows - 2));
13
+ }
14
+ /**
15
+ * Interactive model picker — replaces the input bar while open.
16
+ * - Fetches the provider's model list, falls back to cache
17
+ * - Type to filter, ↑↓ to navigate, Enter to select, Esc to cancel
18
+ * - With a non-matching filter, Enter applies the typed name directly
19
+ * - List is capped so the picker never overflows the terminal
20
+ */
21
+ export function ModelPicker({ currentModel, onSelect, onCancel }) {
22
+ const [models, setModels] = useState(null);
23
+ const [query, setQuery] = useState("");
24
+ const [index, setIndex] = useState(0);
25
+ const { stdout } = useStdout();
26
+ useEffect(() => {
27
+ let cancelled = false;
28
+ const cfg = loadConfig();
29
+ const provider = getActiveProvider(cfg);
30
+ if (provider?.baseURL && provider?.apiKey) {
31
+ fetchModels(provider.baseURL, provider.apiKey)
32
+ .then((m) => {
33
+ if (!cancelled)
34
+ setModels(m);
35
+ })
36
+ .catch(() => {
37
+ if (!cancelled)
38
+ setModels([]);
39
+ });
40
+ }
41
+ else {
42
+ setModels([]);
43
+ }
44
+ return () => { cancelled = true; };
45
+ }, []);
46
+ const terminalRows = stdout.rows || 24;
47
+ const maxListRows = Math.max(4, terminalRows - 11); // chrome + status bar + safety
48
+ const q = query.trim().toLowerCase();
49
+ const filtered = (models ?? []).filter((m) => m.toLowerCase().includes(q));
50
+ const shownIndex = Math.min(index, Math.max(0, filtered.length - 1));
51
+ const windowStart = Math.min(shownIndex, Math.max(0, filtered.length - maxListRows));
52
+ const shown = filtered.slice(windowStart, windowStart + maxListRows);
53
+ const extraAbove = windowStart;
54
+ const extraBelow = filtered.length - (windowStart + shown.length);
55
+ useEffect(() => { setIndex(0); }, [q]);
56
+ useInput((input, key) => {
57
+ const cleanInput = input.replace(/[\r\n]+$/, "");
58
+ const isEnter = key.return || /[\r\n]+$/.test(input);
59
+ if (key.escape) {
60
+ onCancel();
61
+ return;
62
+ }
63
+ if (key.pageDown) {
64
+ if (filtered.length > 0)
65
+ setIndex((i) => Math.min(filtered.length - 1, i + maxListRows));
66
+ return;
67
+ }
68
+ if (key.pageUp) {
69
+ if (filtered.length > 0)
70
+ setIndex((i) => Math.max(0, i - maxListRows));
71
+ return;
72
+ }
73
+ if (filtered.length > 0) {
74
+ if (key.downArrow) {
75
+ setIndex((i) => (i + 1) % filtered.length);
76
+ return;
77
+ }
78
+ if (key.upArrow) {
79
+ setIndex((i) => (i - 1 + filtered.length) % filtered.length);
80
+ return;
81
+ }
82
+ }
83
+ if (isEnter) {
84
+ if (filtered.length > 0)
85
+ onSelect(filtered[shownIndex]);
86
+ else if (q !== "")
87
+ onSelect(q);
88
+ return;
89
+ }
90
+ if (key.backspace || key.delete) {
91
+ setQuery((v) => Array.from(v).slice(0, -1).join(""));
92
+ return;
93
+ }
94
+ if (key.ctrl && input === "u") {
95
+ setQuery("");
96
+ return;
97
+ }
98
+ if (key.ctrl || key.meta || key.tab)
99
+ return;
100
+ if (cleanInput)
101
+ setQuery((v) => v + cleanInput);
102
+ });
103
+ const loading = models === null;
104
+ const empty = models !== null && filtered.length === 0 && q === "";
105
+ // Debounce filter: avoid re-filtering on every keystroke burst
106
+ const debouncedQ = q;
107
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.pickerBorder, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.pickerBorder, children: "\u9009\u62E9\u6A21\u578B" }), _jsx(Text, { color: theme.muted, children: currentModel ? ` (当前: ${currentModel})` : "" })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "\u7B5B\u9009: " }), _jsx(Text, { color: query ? theme.accent : theme.muted, children: query || "输入字符过滤,↑↓/PgUpDn 导航" })] }), loading && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u6B63\u5728\u83B7\u53D6\u6A21\u578B\u5217\u8868..." }) })), empty && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, children: "\u65E0\u6CD5\u83B7\u53D6\u6A21\u578B\u5217\u8868\uFF08\u53EF\u8F93\u5165\u6A21\u578B\u540D\u540E\u6309 Enter\uFF09" }) })), models !== null && extraAbove > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0A\u65B9\u8FD8\u6709 ", extraAbove, " \u4E2A\u6A21\u578B"] }) })), models !== null && shown.map((m, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === shownIndex - windowStart ? theme.accent : theme.muted, children: i === shownIndex - windowStart ? "❯ " : " " }), _jsx(Text, { bold: i === shownIndex - windowStart, color: i === shownIndex - windowStart ? theme.accent : undefined, children: m }), m === currentModel && _jsx(Text, { color: theme.success, children: " \u2190 \u5F53\u524D" })] }, m))), extraBelow > 0 && (_jsx(Box, { children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u2026 \u4E0B\u65B9\u8FD8\u6709 ", extraBelow, " \u4E2A\u6A21\u578B\uFF08\u8F93\u5165\u5B57\u7B26\u8FC7\u6EE4\uFF09"] }) })), models !== null && filtered.length === 0 && debouncedQ !== "" && (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "\u276F " }), _jsxs(Text, { color: theme.accent, bold: true, children: ["\u4F7F\u7528\u81EA\u5B9A\u4E49\u6A21\u578B: ", debouncedQ] })] })), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193/PgUpDn \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u53D6\u6D88" }) })] }));
108
+ }