minimal-agent 0.5.4 → 0.5.6

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.
package/src/ui/App.js CHANGED
@@ -7,31 +7,175 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * 不直接处理键盘输入,那是 InputBox 的事。
8
8
  * ============================================================
9
9
  */
10
- import React from 'react';
11
- import { Box, Text, Static } from 'ink';
10
+ import React, { useDeferredValue } from 'react';
11
+ import { Box, Text } from 'ink';
12
+ import { getWorkingDir } from '../bootstrap/workingDir.js';
13
+ import { listArchives, restoreArchive } from '../context/archive.js';
12
14
  import { saveContext } from '../context/persistContext.js';
15
+ import { sessionFileFor } from '../context/sessionPath.js';
16
+ import { listSessions } from '../context/sessionRegistry.js';
13
17
  import { InputBox } from './InputBox.js';
18
+ import { LiveArea } from './LiveArea.js';
14
19
  import { MessageList } from './MessageList.js';
15
20
  import { StatusLine } from './StatusLine.js';
16
- import { ToolStatus } from './ToolStatus.js';
17
21
  import { useChat } from './hooks/useChat.js';
18
- /** ANSI escape: 清屏 + 光标归位 */
22
+ import { useTerminalSize } from './hooks/useTerminalSize.js';
23
+ /**
24
+ * ANSI escape: 清屏 + 光标归位。
25
+ *
26
+ * 注意:项目里仅这两处保留 process.stdout.write 旁路 Ink:
27
+ * 1) onCompactDone(压缩完)
28
+ * 2) /new 清空历史后
29
+ * CLEAR_SCREEN 是**一次性**写入 + 整屏清空的语义,不存在"反复写新内容到 live 区"
30
+ * 的问题;ESC[2J 清屏后 ink 下次 onRender 会重发完整 lastOutput,lineCount 残留
31
+ * 也不会留下脏行(写在空白处)。其它"用户看到的非消息内容"必须走 chat.ephemeralLine
32
+ * 走 Ink 渲染管线,否则会破坏 log-update 计数。
33
+ */
19
34
  const CLEAR_SCREEN = '\x1b[2J\x1b[H';
20
35
  export function App({ provider, initialHistory }) {
21
- // 持久化挂钩:每次轮次结束 / 清空 / 压缩后由 useChat 触发;fire-and-forget。
22
- const onPersist = React.useCallback((messages) => {
23
- void saveContext(messages);
24
- }, []);
36
+ // v2 multi-session:持久化路径完全由 useChat 内部管控(按 activeSessionId 选文件)。
37
+ // App 不再传 onPersist —— 之前 App 端调 saveContext(messages) 会污染 default session。
25
38
  const chat = useChat({
26
39
  provider,
27
40
  initialHistory,
28
- onPersist,
29
41
  onCompactDone: () => process.stdout.write(CLEAR_SCREEN),
30
42
  });
43
+ // 终端尺寸感知:rows → MessageList 决定窄终端是否折叠 reasoning + LiveArea 收敛预览高;
44
+ // cols → LiveArea 逐行截断防换行
45
+ const { rows: terminalRows, cols: terminalCols } = useTerminalSize();
46
+ /**
47
+ * 用 useDeferredValue 把 history 推后到低优先级渲染队列。
48
+ *
49
+ * 流式期间 transient state 高频 setState 是高优先级,
50
+ * 而 MessageList 重算 turn meta + Static reconcile 比较重,可以"等空闲再做"。
51
+ * React 在繁忙时会让 deferredHistory 保持旧值跳过 MessageList rerender,
52
+ * 直到 streamingText 稳定下来再 commit 新 history。
53
+ *
54
+ * 注意:assistant_message 落定时 history 增长 + streamingText 清空是同一次
55
+ * runQuery yield —— useDeferredValue 不会无限延迟,最终一定会 catch up,
56
+ * Static commit 新 message 进 scrollback 不会丢。
57
+ */
58
+ const deferredHistory = useDeferredValue(chat.history);
31
59
  // /new:内存 reset + 磁盘 clearContext 全部由 chat.clearHistory() 内部完成,await 之后再清屏。
60
+ // ★ deps 用精细字段 chat.clearHistory,不是整个 chat。
61
+ // 理由:useChat 返回的 chat 对象每次 useChat 重渲都是新对象字面量引用。
62
+ // 如果 deps=[chat],流式期 reasoningPreview/streamingText 每 100ms flush 触发
63
+ // useChat rerender → chat 新引用 → handleClear 新引用 → InputBox 收到新 onClear
64
+ // prop → 强制重渲 → 整个 live 区被 Ink 重写一次(看起来就是 spinner 每帧打新行)。
32
65
  const handleClear = React.useCallback(async () => {
33
66
  await chat.clearHistory();
34
67
  process.stdout.write(CLEAR_SCREEN);
35
- }, [chat]);
36
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [null], children: () => (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "minimal-agent" }), _jsx(Text, { color: "gray", children: ` · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断` })] })) }), _jsx(MessageList, { history: chat.history, streamingText: chat.streamingText, streamingReasoning: chat.streamingReasoning }), _jsx(ToolStatus, { status: chat.toolStatus, compacting: chat.compacting }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: chat.submit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(Static, { items: [chat.history.length], children: () => (_jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })) })] }));
68
+ }, [chat.clearHistory]);
69
+ // history ref:在 handleSubmit 闭包里读最新 chat.history,避免每次流式 flush
70
+ // 重建 callback;同时避免把 chat.history 放进 deps 导致 InputBox 频繁 rerender。
71
+ const historyRef = React.useRef(chat.history);
72
+ React.useEffect(() => {
73
+ historyRef.current = chat.history;
74
+ }, [chat.history]);
75
+ /**
76
+ * v2 slash 命令拦截层。
77
+ *
78
+ * InputBox 已经拦截了 /exit /quit /new /compact 不走 onSubmit;
79
+ * 其他 / 开头命令落到这里。命中命令走 chat.ephemeralLine() 把提示作为
80
+ * _ephemeral 的 system 消息推进 history → MessageList <Static> 把它 commit
81
+ * 进 scrollback;不调 chat.submit 避免被 LLM 当成用户输入。
82
+ *
83
+ * 旧方案是 process.stdout.write 旁路 Ink,但那样会破坏 ink log-update 的
84
+ * previousLineCount 计数,下一帧 eraseLines 用过期 N 去清会留下脏行。
85
+ *
86
+ * 支持:
87
+ * /session <name> — 切到指定 session(同 cwd)
88
+ * /sessions — 列当前 cwd 下所有 session
89
+ * /list-archive — 列归档
90
+ * /restore <id> — 恢复归档为当前 session 历史(覆盖+重读)
91
+ * /show <id-suffix> — 打印 tool_call_id 末尾匹配的完整 tool result
92
+ */
93
+ const handleSubmit = React.useCallback(async (text) => {
94
+ // /session <name>
95
+ if (text.startsWith('/session ')) {
96
+ const id = text.slice('/session '.length).trim();
97
+ if (id.length === 0) {
98
+ chat.ephemeralLine('[/session] 用法:/session <name>');
99
+ return;
100
+ }
101
+ await chat.switchSession(id);
102
+ chat.ephemeralLine(`[/session] 已切换到 session "${id}"`);
103
+ return;
104
+ }
105
+ // /sessions
106
+ if (text.trim() === '/sessions') {
107
+ const ids = await listSessions(getWorkingDir());
108
+ const lines = ids
109
+ .map((id) => (id === chat.activeSessionId ? `* ${id}` : ` ${id}`))
110
+ .join('\n');
111
+ chat.ephemeralLine(`[/sessions] 当前 cwd 下的 session(* = 激活):\n${lines}`);
112
+ return;
113
+ }
114
+ // /list-archive
115
+ if (text.trim() === '/list-archive') {
116
+ const entries = await listArchives();
117
+ if (entries.length === 0) {
118
+ chat.ephemeralLine('[/list-archive] 当前 cwd 下无归档。');
119
+ return;
120
+ }
121
+ const lines = entries
122
+ .slice(0, 30)
123
+ .map((e) => ` ${e.id} · ${e.msgCount} msgs · ${e.firstUserPreview || '(empty)'}`)
124
+ .join('\n');
125
+ const tail = entries.length > 30 ? `\n ...(共 ${entries.length} 条,仅显示前 30)` : '';
126
+ chat.ephemeralLine(`[/list-archive] 归档列表(最新优先):\n${lines}${tail}`);
127
+ return;
128
+ }
129
+ // /restore <id>
130
+ if (text.startsWith('/restore ')) {
131
+ const id = text.slice('/restore '.length).trim();
132
+ if (id.length === 0) {
133
+ chat.ephemeralLine('[/restore] 用法:/restore <archive-id>');
134
+ return;
135
+ }
136
+ const messages = await restoreArchive(id);
137
+ if (!messages) {
138
+ chat.ephemeralLine(`[/restore] 未找到 archive id "${id}"`);
139
+ return;
140
+ }
141
+ // 覆盖当前 active session 文件;强制 reload 让 history 替换
142
+ const targetFile = sessionFileFor(getWorkingDir(), chat.activeSessionId);
143
+ await saveContext(messages, targetFile);
144
+ await chat.switchSession(chat.activeSessionId, { force: true });
145
+ chat.ephemeralLine(`[/restore] 已恢复 archive ${id} 到 session "${chat.activeSessionId}"(${messages.length} 条消息)`);
146
+ return;
147
+ }
148
+ // /show <id-suffix>
149
+ if (text.startsWith('/show ')) {
150
+ const suffix = text.slice('/show '.length).trim();
151
+ if (suffix.length === 0) {
152
+ chat.ephemeralLine('[/show] 用法:/show <tool_call_id 末尾 8 字符>');
153
+ return;
154
+ }
155
+ const hit = historyRef.current.find((m) => m.role === 'tool' && m.tool_call_id.endsWith(suffix));
156
+ if (!hit || hit.role !== 'tool') {
157
+ chat.ephemeralLine(`[/show] 未找到 tool_call_id 末尾匹配 "${suffix}" 的 tool result`);
158
+ return;
159
+ }
160
+ chat.ephemeralLine(`[tool result · #${hit.tool_call_id.slice(-8)} · 完整 ${hit.content.split('\n').length} 行]\n${hit.content}`);
161
+ return;
162
+ }
163
+ // 非命令 → 走正常 LLM 流程
164
+ void chat.submit(text);
165
+ },
166
+ // ★ 精细 deps:用 chat 的具体方法/字段,**不是**整个 chat 对象。
167
+ // useChat 每次重渲都返回新的 chat 对象字面量;用 [chat] 会让 handleSubmit
168
+ // 随每次流式 flush(100ms)重建一次 → InputBox 收到新 onSubmit → 强制重渲
169
+ // → Ink 重写 live 区 → spinner 看起来每帧打新行。
170
+ [
171
+ chat.ephemeralLine,
172
+ chat.switchSession,
173
+ chat.activeSessionId,
174
+ chat.submit,
175
+ ]);
176
+ // banner 改作为 Static items[0] 走 MessageList。原因:Ink 5 的 Static 约定
177
+ // 它必须是兄弟节点中第一个,否则 above-sibling 会跟 Static commit 一起被重打
178
+ // 到 stdout(实测看到 banner 反复打印 N 次)。
179
+ const bannerText = `minimal-agent · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断`;
180
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MessageList, { history: deferredHistory, compactGeneration: chat.compactGeneration, bannerText: bannerText, terminalRows: terminalRows }), _jsxs(Box, { flexDirection: "column", width: Math.max(1, terminalCols - 1), children: [_jsx(LiveArea, { progressStage: chat.progressStage, reasoningPreview: chat.reasoningPreview, streamingText: chat.streamingText, runningTools: chat.runningTools, pluginProgress: chat.pluginProgress, terminalCols: terminalCols, terminalRows: terminalRows }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: handleSubmit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })] })] }));
37
181
  }
@@ -16,17 +16,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  * - 保留强制刷新但精准控制时机
17
17
  * ============================================================
18
18
  */
19
- import { useRef, useState, useCallback } from 'react';
19
+ import React, { useRef, useState, useCallback } from 'react';
20
20
  import { Box, Text, useApp, useInput } from 'ink';
21
21
  import { useTextBuffer } from './hooks/useTextBuffer.js';
22
22
  import { usePasteHandler } from './hooks/usePasteHandler.js';
23
- export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
23
+ import { useInputHistory } from './hooks/useInputHistory.js';
24
+ function InputBoxImpl({ onSubmit, disabled, onAbort, onClear, onCompact }) {
24
25
  const buf = useTextBuffer();
26
+ const hist = useInputHistory();
25
27
  const ctrlCCountRef = useRef(0);
26
28
  const ctrlCTimerRef = useRef(null);
27
29
  const { exit } = useApp();
28
- // 精准刷新计数器:只在必要时触发重渲染
29
- const [refreshCounter, setRefreshCounter] = useState(0);
30
+ // 刷新触发器:值本身不读,只用 setter 触发 InputBoxImpl 重渲(→ BufferView 随父重渲)。
31
+ const [, setRefreshCounter] = useState(0);
32
+ // ✅ Ctrl+R 反向搜索模式(null 表示不在搜索)
33
+ const [searchMode, setSearchMode] = useState(null);
30
34
  // ✅ Delete 专用去重:只防止 Windows 双事件,不影响其他键
31
35
  const lastDeleteTime = useRef(0);
32
36
  const DELETE_DEBOUNCE_MS = 20; // 仅 Delete 键用极短防抖
@@ -61,12 +65,68 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
61
65
  return;
62
66
  }
63
67
  // pasteResult === 'continue',继续普通处理
68
+ // ---- ✅ Ctrl+R 反向搜索模式(独占按键流)----
69
+ if (searchMode !== null) {
70
+ // ESC 退出搜索
71
+ if (key.escape) {
72
+ setSearchMode(null);
73
+ return;
74
+ }
75
+ // Enter 选中并提交
76
+ if (key.return) {
77
+ if (searchMode.hit !== null) {
78
+ const text = searchMode.hit;
79
+ setSearchMode(null);
80
+ buf.clear();
81
+ forceRefresh();
82
+ hist.push(text);
83
+ hist.resetCursor();
84
+ onSubmit(text);
85
+ }
86
+ else {
87
+ setSearchMode(null);
88
+ }
89
+ return;
90
+ }
91
+ // Backspace 削 query;query 削空了退出
92
+ if (key.backspace || key.delete) {
93
+ const nextQuery = searchMode.query.slice(0, -1);
94
+ if (nextQuery.length === 0) {
95
+ setSearchMode(null);
96
+ return;
97
+ }
98
+ const nextHit = hist.search(nextQuery)[0] ?? null;
99
+ setSearchMode({ query: nextQuery, hit: nextHit });
100
+ return;
101
+ }
102
+ // Ctrl+C 退出搜索(不退出程序)
103
+ if (key.ctrl && input === 'c') {
104
+ setSearchMode(null);
105
+ return;
106
+ }
107
+ // 字面字符(排除控制键)追加到 query
108
+ if (input && !key.ctrl && !key.meta && !key.return && !key.escape) {
109
+ const nextQuery = searchMode.query + input;
110
+ const nextHit = hist.search(nextQuery)[0] ?? null;
111
+ setSearchMode({ query: nextQuery, hit: nextHit });
112
+ return;
113
+ }
114
+ // 其他按键在搜索模式下静默吞掉
115
+ return;
116
+ }
64
117
  // ---- ESC ----
65
118
  if (key.escape) {
66
119
  if (disabled)
67
120
  onAbort();
68
121
  return;
69
122
  }
123
+ // ---- ✅ Ctrl+R 进入反向搜索(仅在空 buf + 非 disabled)----
124
+ if (key.ctrl && input === 'r') {
125
+ if (!disabled && buf.state.text.length === 0) {
126
+ setSearchMode({ query: '', hit: null });
127
+ }
128
+ return;
129
+ }
70
130
  // ---- Ctrl+C ----
71
131
  if (key.ctrl && input === 'c') {
72
132
  if (buf.state.text.length > 0) {
@@ -96,6 +156,9 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
96
156
  const text = buf.state.text.trim();
97
157
  if (text.length === 0)
98
158
  return;
159
+ // 入历史(所有非空提交都记,包括 slash 命令;连续重复 hist 内部去重)
160
+ hist.push(text);
161
+ hist.resetCursor();
99
162
  if (text === '/exit' || text === '/quit')
100
163
  exit();
101
164
  else if (text === '/new' || text === '/clear') {
@@ -133,11 +196,29 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
133
196
  return;
134
197
  }
135
198
  if (key.upArrow) {
199
+ // 首行按 ↑ → 翻历史;中间行 → 正常移动光标
200
+ if (buf.layout.cursorRow === 0) {
201
+ const prev = hist.prev();
202
+ if (prev !== null) {
203
+ buf.setText(prev);
204
+ forceRefresh();
205
+ }
206
+ return;
207
+ }
136
208
  buf.moveUp();
137
209
  forceRefresh();
138
210
  return;
139
211
  }
140
212
  if (key.downArrow) {
213
+ // 末行按 ↓ → 翻历史;中间行 → 正常移动光标
214
+ const lastRow = buf.layout.lines.length - 1;
215
+ if (buf.layout.cursorRow >= lastRow) {
216
+ const nxt = hist.next();
217
+ // next 返 null 表示走到 "新输入" 位 → 清空缓冲
218
+ buf.setText(nxt ?? '');
219
+ forceRefresh();
220
+ return;
221
+ }
141
222
  buf.moveDown();
142
223
  forceRefresh();
143
224
  return;
@@ -213,20 +294,50 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
213
294
  forceRefresh();
214
295
  }
215
296
  });
216
- // ✅ 传递 refreshCounter 作为 prop(不放入 key)
217
- return (_jsx(BufferView, { buf: buf, disabled: disabled, refreshCounter: refreshCounter }));
297
+ // ✅ 搜索模式下覆盖整个 BufferView,显示 (reverse-i-search)`<query>': <hit>
298
+ if (searchMode !== null) {
299
+ return _jsx(ReverseSearchView, { query: searchMode.query, hit: searchMode.hit });
300
+ }
301
+ // BufferView 非 memo:InputBoxImpl 因按键 setState(useTextBuffer 的 buf state
302
+ // 或 refreshCounter 任一变化)重渲时,BufferView 作为子组件随之重渲、重读 buf.layout。
303
+ // 故无需把刷新计数透传给它(透传是冗余的)。
304
+ return _jsx(BufferView, { buf: buf, disabled: disabled });
305
+ }
306
+ /**
307
+ * v2.x 修复:用 React.memo 包装 InputBox。
308
+ *
309
+ * 根因:App.tsx 之前用 deps=[chat] 包 handleClear / handleSubmit;chat 每次
310
+ * useChat 重渲都是新对象引用(流式期 reasoningPreview/streamingText 每 100ms flush,
311
+ * ~10 Hz)→ handleClear / handleSubmit 引用频繁变 → InputBox 收到新 onClear / onSubmit
312
+ * → 不 memo 时强制重渲 → Ink 收到 InputBox 子树输出变化 → 整个 live 区被重写 →
313
+ * 看起来 spinner 每帧打新行。
314
+ *
315
+ * App.tsx deps 已改为精细字段(chat.clearHistory / chat.submit 等),引用稳定;
316
+ * 配合此处 React.memo,InputBox 在 onSubmit / onAbort / onClear / onCompact /
317
+ * disabled 真正变化时才重渲。
318
+ */
319
+ export const InputBox = React.memo(InputBoxImpl);
320
+ // ---------- 反向搜索视图 ----------
321
+ function ReverseSearchView({ query, hit, }) {
322
+ // hit 可能含换行;仅展示首行避免撑爆 InputBox
323
+ const hitFirstLine = hit !== null ? hit.split('\n', 1)[0] : null;
324
+ const hitDisplay = hitFirstLine === null
325
+ ? '<no match>'
326
+ : hit !== null && hit.includes('\n')
327
+ ? `${hitFirstLine} …`
328
+ : hitFirstLine;
329
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "magenta", paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "magenta", bold: true, children: '(reverse-i-search)`' }), _jsx(Text, { color: "yellow", children: query }), _jsx(Text, { color: "magenta", bold: true, children: "': " }), _jsx(Text, { color: hitFirstLine === null ? 'gray' : 'white', children: hitDisplay })] }), _jsx(Box, { children: _jsx(Text, { color: "gray", dimColor: true, children: "Enter \u63D0\u4EA4 \u00B7 ESC \u53D6\u6D88 \u00B7 Backspace \u5220\u5B57\u7B26" }) })] }));
218
330
  }
219
- function BufferView({ buf, disabled, refreshCounter }) {
220
- // 关键:用 useMemo 依赖 refreshCounter,而非改变 key
221
- // 这样不会重建组件,只是强制重新计算 layout
331
+ function BufferView({ buf, disabled }) {
332
+ // buf.layout useTextBuffer 的 useMemo([state]) 维护:父组件每次重渲传入的 buf
333
+ // 都已带最新 layout,这里直接读即可(BufferView 故意不 memo,随父重渲)。
222
334
  const { lines, cursorRow, cursorCol } = buf.layout;
223
335
  const promptColor = disabled ? 'gray' : 'cyan';
224
336
  const prompt = disabled ? '⏳ ' : '> ';
225
337
  return (_jsx(Box, { borderStyle: "round", borderColor: promptColor, paddingX: 1, flexDirection: "column", children: lines.map((line, row) => {
226
338
  const isFirst = row === 0;
227
339
  const isCursorRow = row === cursorRow && !disabled;
228
- // key 保持稳定(只用 row),避免组件重建
229
- // refreshCounter 通过 useMemo 依赖间接触发更新
340
+ // key 只用 row:保持稳定,避免逐行重建(layout 变化由父级重渲带下来)
230
341
  return (_jsxs(Box, { children: [_jsx(Text, { color: promptColor, bold: true, children: isFirst ? prompt : ' ' }), isCursorRow ? _jsx(CursorLine, { line: line, col: cursorCol }) : _jsx(Text, { children: line || ' ' })] }, row));
231
342
  }) }));
232
343
  }
@@ -0,0 +1,132 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ============================================================
4
+ * src/ui/LiveArea.tsx —— 输入框上方的本轮过程区(有界多行流式 live 区)
5
+ * ------------------------------------------------------------
6
+ * 结构(自上而下):
7
+ * ┌ 状态行(恒 1 行):动画 spinner + 阶段标签 + 已耗时 (+ plugin 进度)
8
+ * └ 内容区(0..N 行,有界):随阶段切换——
9
+ * thinking → reasoningPreview 尾部最近 N 行(gray dim,首行 💭)
10
+ * streaming → streamingText 尾部最近 N 行(默认亮色 = 答案是主角)
11
+ * tool_executing → runningTools 逐行人话描述(gray dim)
12
+ * compacting/idle→ 无内容行
13
+ *
14
+ * ★ 为什么这样能既"多行打字机 + 流式答案"又**不刷屏**(详见 liveViewport.ts 头部):
15
+ * - INV-1:每行都用 wrapToDisplayLines / clampWidthStart 按 CJK/emoji=2 钳到
16
+ * ≤ cols - LIVE_RIGHT_MARGIN → 终端永不自动折行 → ink eraseLines 每帧精确。
17
+ * - INV-2:内容区取**尾部最近 N 行**(contentMaxLines 自适应终端高度),保证
18
+ * live 区总高度 < 终端行数 → 不触发 ink 的 outputHeight>=rows 全屏重写。
19
+ * 完整思维链/答案由 <Static>(MessageList)write-once 呈现,不进 live 区。
20
+ *
21
+ * ★ 动画 spinner + 已耗时:非 idle 时挂一个 ~150ms tick 强制 re-render,保证
22
+ * "无 token 到达的纯等待期"spinner 仍在跳、秒数仍在走 → 消除"卡住"观感。
23
+ * 流式内容本身走 useChat 的 100ms 节流;ink 内部 32ms throttle 合并,频率安全。
24
+ * ============================================================
25
+ */
26
+ import React, { useEffect, useRef, useState } from 'react';
27
+ import { Box, Text } from 'ink';
28
+ import { LIVE_RIGHT_MARGIN, clampWidthStart, wrapToDisplayLines, } from './liveViewport.js';
29
+ const STAGE_LABELS = {
30
+ thinking: '思考中',
31
+ streaming: '回答中',
32
+ tool_executing: '调用工具',
33
+ compacting: '压缩历史',
34
+ };
35
+ /** 动画 spinner 帧(charWidth 上界按 2 计;终端实际多为 1,算宽无害)。 */
36
+ const SPINNER_FRAMES = ['✶', '✸', '✹', '✺', '✹', '✷'];
37
+ /** spinner 推进 + 强制 re-render 间隔(ms)。~7Hz,远低于 ink 31Hz throttle,Windows 安全。 */
38
+ const SPINNER_INTERVAL_MS = 150;
39
+ /** 内容区行数硬顶(大终端也不铺满,保持清爽)。 */
40
+ const CONTENT_HARD_CAP = 8;
41
+ /** 终端高度里预留给 status(1) + 输入框(3) + StatusLine(1) + error/interrupt + 安全余量。 */
42
+ const RESERVED_ROWS = 9;
43
+ /** reasoning 行 💭 前缀 / 续行缩进的预留宽度(💭=2 + 空格;上界 4 更稳)。 */
44
+ const REASONING_PREFIX_RESERVE = 4;
45
+ /** 工具 argsPreview fallback 截断长度。 */
46
+ const TOOL_ARGS_MAX = 60;
47
+ /** 内容区最多显示几行:自适应终端高度,留足 status/input 余量(满足 INV-2)。 */
48
+ export function contentMaxLines(terminalRows) {
49
+ return Math.max(2, Math.min(CONTENT_HARD_CAP, terminalRows - RESERVED_ROWS));
50
+ }
51
+ /** 取数组尾部最近 n 个元素。 */
52
+ function lastN(arr, n) {
53
+ return arr.length <= n ? arr : arr.slice(arr.length - n);
54
+ }
55
+ function toolDesc(t) {
56
+ if (t.argsFriendly && t.argsFriendly.length > 0)
57
+ return t.argsFriendly;
58
+ const a = t.argsPreview.length > TOOL_ARGS_MAX
59
+ ? `${t.argsPreview.slice(0, TOOL_ARGS_MAX - 1)}…`
60
+ : t.argsPreview;
61
+ return `${t.toolName}(${a})`;
62
+ }
63
+ /**
64
+ * 纯函数:把一帧的 props(+ 动画 frame / 已耗时秒数)算成 LiveView。
65
+ * idle 返回 null。导出供单测断言 INV-1(每行宽)/ INV-2(行数)/ 颜色层次。
66
+ */
67
+ export function buildLiveView(props) {
68
+ const { progressStage, reasoningPreview, streamingText, runningTools, pluginProgress, terminalCols, terminalRows, frame, elapsedSec, } = props;
69
+ if (progressStage === 'idle')
70
+ return null;
71
+ const stage = progressStage;
72
+ const maxWidth = Math.max(8, terminalCols - LIVE_RIGHT_MARGIN);
73
+ const maxLines = contentMaxLines(terminalRows);
74
+ // ---- 状态行 ----
75
+ let status = `${frame} ${STAGE_LABELS[stage]} (${elapsedSec}s)`;
76
+ if (pluginProgress) {
77
+ status += ` · [plugin:${pluginProgress.pluginId}] ${pluginProgress.current}/${pluginProgress.max ?? '?'}${pluginProgress.message ? ` ${pluginProgress.message}` : ''}`;
78
+ }
79
+ status = clampWidthStart(status, maxWidth);
80
+ // ---- 内容区 ----
81
+ let contentLines = [];
82
+ let contentDim = true;
83
+ if (stage === 'thinking' && reasoningPreview.length > 0) {
84
+ const wrapped = wrapToDisplayLines(reasoningPreview, maxWidth - REASONING_PREFIX_RESERVE);
85
+ contentLines = lastN(wrapped, maxLines).map((line, i) => i === 0 ? `💭 ${line}` : ` ${line}`);
86
+ contentDim = true;
87
+ }
88
+ else if (stage === 'streaming' && streamingText.length > 0) {
89
+ contentLines = lastN(wrapToDisplayLines(streamingText, maxWidth), maxLines);
90
+ contentDim = false; // 答案 = 主角,默认亮色
91
+ }
92
+ else if (stage === 'tool_executing' && runningTools.length > 0) {
93
+ contentLines = lastN(runningTools, maxLines).map((t) => clampWidthStart(` ${toolDesc(t)}`, maxWidth));
94
+ contentDim = true;
95
+ }
96
+ return {
97
+ statusLine: status,
98
+ statusColor: stage === 'compacting' ? 'magenta' : 'cyan',
99
+ contentLines,
100
+ contentDim,
101
+ };
102
+ }
103
+ export const LiveArea = React.memo(function LiveArea(props) {
104
+ const { progressStage } = props;
105
+ // 计时起点:idle→非idle 那一刻记下,idle 清零。在 render 中惰性初始化 ref(幂等,安全)。
106
+ const startRef = useRef(null);
107
+ if (progressStage === 'idle') {
108
+ startRef.current = null;
109
+ }
110
+ else if (startRef.current === null) {
111
+ startRef.current = Date.now();
112
+ }
113
+ // 非 idle 时 ~150ms tick:强制 re-render,让 spinner/秒数在纯等待期也持续动。
114
+ const [, setTick] = useState(0);
115
+ useEffect(() => {
116
+ if (progressStage === 'idle')
117
+ return;
118
+ const timer = setInterval(() => setTick((t) => (t + 1) % 1_000_000), SPINNER_INTERVAL_MS);
119
+ return () => clearInterval(timer);
120
+ }, [progressStage]);
121
+ if (progressStage === 'idle')
122
+ return null;
123
+ const now = Date.now();
124
+ const elapsedSec = startRef.current
125
+ ? Math.max(0, Math.floor((now - startRef.current) / 1000))
126
+ : 0;
127
+ const frame = SPINNER_FRAMES[Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length];
128
+ const view = buildLiveView({ ...props, frame, elapsedSec });
129
+ if (!view)
130
+ return null;
131
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: view.statusColor, bold: true, wrap: "truncate", children: view.statusLine }), view.contentLines.map((line, i) => (_jsx(Text, { color: view.contentDim ? 'gray' : undefined, dimColor: view.contentDim, wrap: "truncate", children: line.length > 0 ? line : ' ' }, `live-${i}`)))] }));
132
+ });