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/package.json +1 -1
- package/src/context/archive.js +117 -0
- package/src/context/persistContext.js +33 -1
- package/src/context/sessionPath.js +59 -4
- package/src/context/sessionRegistry.js +104 -0
- package/src/llm/client.js +16 -2
- package/src/loop.js +191 -35
- package/src/prompts/system.js +16 -16
- package/src/tools/read/read.js +5 -1
- package/src/tools/shared/fileState.js +23 -0
- package/src/ui/App.js +155 -11
- package/src/ui/InputBox.js +122 -11
- package/src/ui/LiveArea.js +132 -0
- package/src/ui/MessageList.js +213 -12
- package/src/ui/ProgressPanel.js +54 -0
- package/src/ui/StatusLine.js +21 -2
- package/src/ui/StreamingBlock.js +11 -0
- package/src/ui/ToolStatus.js +2 -2
- package/src/ui/hooks/useChat.js +407 -105
- package/src/ui/hooks/useInputHistory.js +186 -0
- package/src/ui/hooks/useTerminalSize.js +31 -0
- package/src/ui/hooks/useTokenUsage.js +0 -1
- package/src/ui/liveViewport.js +176 -0
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
|
|
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
|
-
|
|
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
|
-
//
|
|
22
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/ui/InputBox.js
CHANGED
|
@@ -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
|
-
|
|
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 [
|
|
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
|
-
// ✅
|
|
217
|
-
|
|
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
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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
|
-
//
|
|
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
|
+
});
|