minimal-agent 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,42 +16,27 @@ 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
- export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
24
+ function InputBoxImpl({ onSubmit, disabled, onAbort, onClear, onCompact }) {
25
25
  const buf = useTextBuffer();
26
26
  const hist = useInputHistory();
27
27
  const ctrlCCountRef = useRef(0);
28
28
  const ctrlCTimerRef = useRef(null);
29
29
  const { exit } = useApp();
30
- // 精准刷新计数器:只在必要时触发重渲染
31
- const [refreshCounter, setRefreshCounter] = useState(0);
32
- // ✅ 精准刷新函数
33
- const forceRefresh = useCallback(() => {
34
- setRefreshCounter((c) => c + 1);
35
- }, []);
36
- // ✅ IME 输入缓冲:非 ASCII 字符(CJK/emoji)累积后批量刷新,减少 rerender 频率
37
- const imeBufRef = useRef('');
38
- const imeTimerRef = useRef(null);
39
- const flushImeBuffer = useCallback(() => {
40
- if (imeTimerRef.current) {
41
- clearTimeout(imeTimerRef.current);
42
- imeTimerRef.current = null;
43
- }
44
- if (imeBufRef.current.length > 0) {
45
- buf.insert(imeBufRef.current);
46
- imeBufRef.current = '';
47
- forceRefresh();
48
- }
49
- }, [buf, forceRefresh]);
30
+ // 刷新触发器:值本身不读,只用 setter 触发 InputBoxImpl 重渲(→ BufferView 随父重渲)。
31
+ const [, setRefreshCounter] = useState(0);
50
32
  // ✅ Ctrl+R 反向搜索模式(null 表示不在搜索)
51
33
  const [searchMode, setSearchMode] = useState(null);
52
34
  // ✅ Delete 专用去重:只防止 Windows 双事件,不影响其他键
53
35
  const lastDeleteTime = useRef(0);
54
36
  const DELETE_DEBOUNCE_MS = 20; // 仅 Delete 键用极短防抖
37
+ const forceRefresh = useCallback(() => {
38
+ setRefreshCounter((c) => c + 1);
39
+ }, []);
55
40
  // ✅ 粘贴处理 Hook:支持多行粘贴
56
41
  // v0.3 设计:Enter 始终发送, Alt+Enter(Mac: Option+Enter)换行, 粘贴期间拦截 Enter
57
42
  const { handleInput: handlePasteInput } = usePasteHandler({
@@ -145,7 +130,6 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
145
130
  // ---- Ctrl+C ----
146
131
  if (key.ctrl && input === 'c') {
147
132
  if (buf.state.text.length > 0) {
148
- flushImeBuffer();
149
133
  buf.clear();
150
134
  forceRefresh();
151
135
  return;
@@ -169,7 +153,6 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
169
153
  // - Ctrl+Enter:返回 'continue' → 这里也执行**强制发送**
170
154
  // - 粘贴期间 + Enter:返回 'enter-in-paste' → **忽略**(防误发)
171
155
  if (key.return) {
172
- flushImeBuffer(); // 先提交残留 CJK 输入
173
156
  const text = buf.state.text.trim();
174
157
  if (text.length === 0)
175
158
  return;
@@ -305,43 +288,35 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
305
288
  // Alt+Enter 在多数终端发 \x1b\r:Ink 把 \x1b 剥掉后 input='\r'、key.return=false、
306
289
  // key.meta=false,从这里落地。把 \r/\r\n 统一成 \n,textBuffer 才能真切分逻辑行
307
290
  // (否则 layout 永远 1 行 → ↑/↓ 失效、输入框不长高,光标只能左右)。
308
- //
309
- // IME 兼容优化(方案C:启发式过滤 + 延迟刷新):
310
- // - ASCII 可打印字符 → 立即插入+刷新(英文打字无延迟)
311
- // - 非 ASCII 字符(CJK/emoji)→ 累积到 imeBuf,50ms 后批量刷新
312
- // (合并连续 CJK 输入,减少 Ink 整树 rerender 次数)
313
291
  if (input && !key.meta) {
314
292
  const normalized = input.replace(/\r\n?/g, '\n');
315
- if (normalized.length === 0)
316
- return;
317
- // ASCII 可打印 + 换行:立即处理
318
- if (/^[\x20-\x7E\n]+$/.test(normalized)) {
319
- flushImeBuffer(); // 先提交残留 CJK
320
- buf.insert(normalized);
321
- forceRefresh();
322
- return;
323
- }
324
- // 非 ASCII(CJK / emoji / 特殊字符):累积后延迟刷新
325
- imeBufRef.current += normalized;
326
- if (!imeTimerRef.current) {
327
- imeTimerRef.current = setTimeout(() => {
328
- if (imeBufRef.current.length > 0) {
329
- buf.insert(imeBufRef.current);
330
- imeBufRef.current = '';
331
- forceRefresh();
332
- }
333
- imeTimerRef.current = null;
334
- }, 50);
335
- }
293
+ buf.insert(normalized);
294
+ forceRefresh();
336
295
  }
337
296
  });
338
297
  // ✅ 搜索模式下覆盖整个 BufferView,显示 (reverse-i-search)`<query>': <hit>
339
298
  if (searchMode !== null) {
340
299
  return _jsx(ReverseSearchView, { query: searchMode.query, hit: searchMode.hit });
341
300
  }
342
- // 传递 refreshCounter 作为 prop(不放入 key)
343
- return (_jsx(BufferView, { buf: buf, disabled: disabled, refreshCounter: refreshCounter }));
301
+ // BufferView memo:InputBoxImpl 因按键 setState(useTextBuffer 的 buf state
302
+ // 或 refreshCounter 任一变化)重渲时,BufferView 作为子组件随之重渲、重读 buf.layout。
303
+ // 故无需把刷新计数透传给它(透传是冗余的)。
304
+ return _jsx(BufferView, { buf: buf, disabled: disabled });
344
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);
345
320
  // ---------- 反向搜索视图 ----------
346
321
  function ReverseSearchView({ query, hit, }) {
347
322
  // hit 可能含换行;仅展示首行避免撑爆 InputBox
@@ -353,17 +328,16 @@ function ReverseSearchView({ query, hit, }) {
353
328
  : hitFirstLine;
354
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" }) })] }));
355
330
  }
356
- function BufferView({ buf, disabled, refreshCounter }) {
357
- // 关键:用 useMemo 依赖 refreshCounter,而非改变 key
358
- // 这样不会重建组件,只是强制重新计算 layout
331
+ function BufferView({ buf, disabled }) {
332
+ // buf.layout useTextBuffer 的 useMemo([state]) 维护:父组件每次重渲传入的 buf
333
+ // 都已带最新 layout,这里直接读即可(BufferView 故意不 memo,随父重渲)。
359
334
  const { lines, cursorRow, cursorCol } = buf.layout;
360
335
  const promptColor = disabled ? 'gray' : 'cyan';
361
336
  const prompt = disabled ? '⏳ ' : '> ';
362
337
  return (_jsx(Box, { borderStyle: "round", borderColor: promptColor, paddingX: 1, flexDirection: "column", children: lines.map((line, row) => {
363
338
  const isFirst = row === 0;
364
339
  const isCursorRow = row === cursorRow && !disabled;
365
- // key 保持稳定(只用 row),避免组件重建
366
- // refreshCounter 通过 useMemo 依赖间接触发更新
340
+ // key 只用 row:保持稳定,避免逐行重建(layout 变化由父级重渲带下来)
367
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));
368
342
  }) }));
369
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
+ });
@@ -1,30 +1,222 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ============================================================
4
+ * src/ui/MessageList.tsx —— 消息历史展示
5
+ * ------------------------------------------------------------
6
+ * 渲染历史消息(system 不显示)。
7
+ * 使用 Ink 5.x 的 <Static> commit 历史到终端 scrollback:
8
+ * 鼠标滚轮可以自然翻阅,性能也更好(已 commit 的行不会再 rerender)。
9
+ *
10
+ * 流式中的 assistant 文本不再在这里"假消息"显示——
11
+ * streaming 阶段不渲染 live answer,turn 结束后再以正式的
12
+ * assistant Message 进入 history 并被 <Static> commit。
13
+ *
14
+ * banner 也走 Static 流(items[0])。
15
+ * Ink 5 的 Static 约定要求 Static 是兄弟节点中第一个,否则它上方的 live
16
+ * 兄弟会跟 Static commit 一起被重打到 stdout(用户实测看到 banner 被
17
+ * 反复打印 N 次)。把 banner 作为 Static 的第一个 item,banner 自然
18
+ * commit 进 scrollback,启动时打印一次,不再反复出现。
19
+ *
20
+ * v2 新增:
21
+ * - assistant 分支渲染 reasoning_content / reasoning(💭 dim gray);
22
+ * 窄终端(rows<28)折叠 2 行 + "▾ N 行 reasoning 折叠"
23
+ * - tool 分支显示 tool_call_id 末 8 字符作为 [tool result · #abc12345],
24
+ * "▾ 完整 N 行(输入 /show xxx 查看)" 提示用户用命令展开
25
+ * - user 分支前插 turn 头横线 "────── Turn N · 14:32 · 8s · 6 tools ──────",
26
+ * turn 边界扫描结果由 useMemo 一次性算出,挂在 StaticItem 上
27
+ *
28
+ * 样式约定:
29
+ * banner: 青色加粗 minimal-agent + 暗灰色 provider/快捷键提示
30
+ * user: 整条青色 + "> " 前缀(输入;前置 turn 横线,若 turnMeta 非空)
31
+ * assistant: reasoning 暗灰带 💭,content 绿色 ⏺ 锚点 + 默认亮色正文(输出),tool_calls 黄色
32
+ * tool: 暗灰色,折叠(默认只显示前 3 行,末尾提示用 /show 查看)
33
+ * ============================================================
34
+ */
35
+ import React from 'react';
36
+ import { Box, Static, Text } from 'ink';
37
+ /** 中断标记 user message 的固定内容(loop.ts 写入) */
38
+ const INTERRUPT_USER_CONTENT = '(用户按下了 ESC/Ctrl+C 中断了任务)';
3
39
  const MAX_TOOL_PREVIEW_LINES = 3;
4
- export function MessageList({ history, streamingText, streamingReasoning }) {
5
- return (_jsxs(Box, { flexDirection: "column", children: [history.map((m, i) => (_jsx(MessageRow, { message: m }, i))), streamingReasoning.length > 0 && (_jsx(Box, { marginBottom: 1, flexDirection: "column", children: (() => {
6
- const MAX_LINES = 5;
7
- const THRESHOLD = 300;
8
- if (streamingReasoning.length <= THRESHOLD) {
9
- return (_jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDCA1 ", streamingReasoning] }));
10
- }
11
- const allLines = streamingReasoning.split('\n');
12
- const tailLines = allLines.slice(-MAX_LINES);
13
- const omitted = allLines.length - MAX_LINES;
14
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDCA1 ... (", Math.round(streamingReasoning.length / 1000), "K\u5B57, \u7701\u7565 ", omitted, " \u884C)"] }), tailLines.slice(0, -1).map((line, i) => (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', line || ' '] }, i))), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', tailLines[tailLines.length - 1] || ' '] })] }));
15
- })() })), streamingText.length > 0 && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: streamingText }) }))] }));
40
+ /** 窄终端 reasoning 折叠阈值(行)—— 小于此值时 reasoning 只显示首 2 行 */
41
+ const NARROW_TERMINAL_ROWS = 28;
42
+ /** reasoning 超过此字符数截尾 —— 防撑爆历史 */
43
+ const REASONING_MAX_CHARS = 2000;
44
+ /**
45
+ * assistant message 的两个字符串型 reasoning 字段拼成单字符串。
46
+ * `reasoning_details` 是结构化数组,本期不展开。两个字段任一非空即返回拼接结果;
47
+ * 都空返回 ''
48
+ *
49
+ * 导出供单测断言。
50
+ */
51
+ export function combineReasoning(message) {
52
+ const parts = [];
53
+ if (typeof message.reasoning_content === 'string' &&
54
+ message.reasoning_content.length > 0) {
55
+ parts.push(message.reasoning_content);
56
+ }
57
+ if (typeof message.reasoning === 'string' && message.reasoning.length > 0) {
58
+ parts.push(message.reasoning);
59
+ }
60
+ return parts.join('\n');
61
+ }
62
+ /**
63
+ * 扫一遍 history,按 user message 切分 turn,返回每条 message 对应的 turnMeta。
64
+ * 返回数组长度 = history.length,与 history 一一对应:
65
+ * - user message(非中断)→ 该 turn 的 TurnMeta(turnN 递增,>=1)
66
+ * - 中断标记 user / assistant / tool / system → null
67
+ *
68
+ * 算法:
69
+ * 1) 先识别"turn 起点":所有非中断 user message
70
+ * 2) 每个 turn 起点 turnN 从 1 递增
71
+ * 3) turn 结束位置 = 下一个 turn 起点之前(或 history 末尾)
72
+ * 4) durationMs = turn 内最后一条 assistant/tool 的 timestamp - user.timestamp
73
+ * 5) toolsCount = turn 内所有 assistant.tool_calls?.length 累加
74
+ *
75
+ * 导出供单测断言。
76
+ */
77
+ export function summarizeTurn(history) {
78
+ const result = new Array(history.length).fill(null);
79
+ // 找出所有 turn 起点的 index
80
+ const turnStartIndices = [];
81
+ for (let i = 0; i < history.length; i++) {
82
+ const m = history[i];
83
+ if (m.role === 'user' && m.content !== INTERRUPT_USER_CONTENT) {
84
+ turnStartIndices.push(i);
85
+ }
86
+ }
87
+ for (let t = 0; t < turnStartIndices.length; t++) {
88
+ const startIdx = turnStartIndices[t];
89
+ const endIdx = t + 1 < turnStartIndices.length
90
+ ? turnStartIndices[t + 1] - 1
91
+ : history.length - 1;
92
+ const startMsg = history[startIdx];
93
+ const userTimestamp = startMsg.timestamp ?? Date.now();
94
+ let lastResponseTs = null;
95
+ let toolsCount = 0;
96
+ for (let j = startIdx + 1; j <= endIdx; j++) {
97
+ const m = history[j];
98
+ if (m.role === 'assistant') {
99
+ toolsCount += m.tool_calls?.length ?? 0;
100
+ if (typeof m.timestamp === 'number')
101
+ lastResponseTs = m.timestamp;
102
+ }
103
+ else if (m.role === 'tool') {
104
+ if (typeof m.timestamp === 'number')
105
+ lastResponseTs = m.timestamp;
106
+ }
107
+ }
108
+ const durationMs = lastResponseTs !== null ? Math.max(0, lastResponseTs - userTimestamp) : 0;
109
+ result[startIdx] = {
110
+ turnN: t + 1,
111
+ durationMs,
112
+ toolsCount,
113
+ timestamp: userTimestamp,
114
+ };
115
+ }
116
+ return result;
16
117
  }
17
- function MessageRow({ message }) {
118
+ /** 格式化 timestamp HH:MM(本地时区) */
119
+ function formatTime(ts) {
120
+ return new Date(ts).toTimeString().slice(0, 5);
121
+ }
122
+ /** 格式化耗时:<1s / 1.2s / 3m */
123
+ function formatDuration(ms) {
124
+ if (ms < 1000)
125
+ return '<1s';
126
+ if (ms < 60_000)
127
+ return `${(ms / 1000).toFixed(1)}s`;
128
+ if (ms < 3_600_000)
129
+ return `${Math.floor(ms / 60_000)}m`;
130
+ return `${Math.floor(ms / 3_600_000)}h`;
131
+ }
132
+ /**
133
+ * React.memo 包装。
134
+ *
135
+ * props 浅比较够用:
136
+ * - history 引用变化才需要重算 items(useMemo 依赖正确)
137
+ * - compactGeneration 数字
138
+ * - bannerText / terminalRows 都是基础类型 / 启动后稳定
139
+ *
140
+ * App.tsx 中用 useDeferredValue(chat.history) 把 history 推后到低优先级队列,
141
+ * 配合此 memo,streaming 期间高频 setStreamingText 不再带着 MessageList
142
+ * 重算 items + Static reconcile。
143
+ */
144
+ export const MessageList = React.memo(function MessageList({ history, compactGeneration, bannerText, terminalRows, }) {
145
+ const items = React.useMemo(() => {
146
+ const turnMetas = summarizeTurn(history);
147
+ return [
148
+ ...(bannerText
149
+ ? [
150
+ {
151
+ kind: 'banner',
152
+ text: bannerText,
153
+ key: `banner-${bannerText.slice(0, 10)}`,
154
+ },
155
+ ]
156
+ : []),
157
+ ...history.map((m, idx) => ({
158
+ kind: 'message',
159
+ msg: m,
160
+ turnMeta: turnMetas[idx],
161
+ // 纯派生 key:有 id 用 id,没有就用 idx 兜底。绝不在 render 期 mutate 消息对象
162
+ // (<Static> 已 commit 的行永不重绘,key 仅在 append 对账时用,idx 已足够稳定)。
163
+ key: m.id ?? `message-${idx}`,
164
+ })),
165
+ ];
166
+ }, [bannerText, history]);
167
+ // 窄终端判定(terminalRows 缺失时默认不折叠,保留完整 reasoning)
168
+ const foldReasoning = typeof terminalRows === 'number' && terminalRows < NARROW_TERMINAL_ROWS;
169
+ return (_jsx(Static, { items: items, children: (item) => {
170
+ if (item.kind === 'banner') {
171
+ return _jsx(BannerRow, { text: item.text }, item.key);
172
+ }
173
+ return (_jsx(MessageRow, { message: item.msg, turnMeta: item.turnMeta, foldReasoning: foldReasoning }, item.key));
174
+ } }, compactGeneration));
175
+ });
176
+ function BannerRow({ text }) {
177
+ // text 形如 "minimal-agent · provider/model · /new ... · Ctrl+C 中断"
178
+ // 拆出第一个 token "minimal-agent" 走加粗青色,剩下走暗灰。
179
+ const splitAt = text.indexOf(' ·');
180
+ const head = splitAt >= 0 ? text.slice(0, splitAt) : text;
181
+ const tail = splitAt >= 0 ? text.slice(splitAt) : '';
182
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: head }), tail.length > 0 && _jsx(Text, { color: "gray", children: tail })] }));
183
+ }
184
+ function MessageRow({ message, turnMeta, foldReasoning, }) {
18
185
  switch (message.role) {
19
- case 'system':
20
- return null; // 不展示系统提示词
21
- case 'user':
22
- return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: '> ' }), _jsx(Text, { children: message.content })] }));
186
+ case 'system': {
187
+ // v2.x:ephemeral system message 是 UI 临时提示(slash 命令输出等),
188
+ // 用暗灰单行风格显示;正经 system prompt(_ephemeral=undefined)依然 hide。
189
+ const ephemeral = message._ephemeral === true;
190
+ if (!ephemeral)
191
+ return null;
192
+ const lines = message.content.split('\n');
193
+ return (_jsx(Box, { marginBottom: 1, paddingLeft: 2, children: _jsx(Box, { flexDirection: "column", children: lines.map((line, i) => (_jsx(Text, { color: "gray", dimColor: true, children: line }, `eph-${i}`))) }) }));
194
+ }
195
+ case 'user': {
196
+ const headerLine = turnMeta !== null
197
+ ? `────── Turn ${turnMeta.turnN} · ${formatTime(turnMeta.timestamp)} · ${formatDuration(turnMeta.durationMs)} · ${turnMeta.toolsCount} tools ──────`
198
+ : null;
199
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [headerLine !== null && (_jsx(Text, { color: "gray", dimColor: true, children: headerLine })), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: '> ' }), _jsx(Text, { color: "cyan", children: message.content })] })] }));
200
+ }
23
201
  case 'assistant': {
24
202
  // assistant 可能只有 tool_calls 没有 content(content 为 null)
25
203
  const text = message.content ?? '';
26
204
  const tcCount = message.tool_calls?.length ?? 0;
27
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [text.length > 0 && _jsx(Text, { children: text }), tcCount > 0 && (_jsx(Text, { color: "yellow", dimColor: true, children: ` └─ 调用了 ${tcCount} 个工具:${(message.tool_calls ?? [])
205
+ // reasoning 拼接
206
+ let reasoningText = combineReasoning(message);
207
+ let truncatedCharsHint = '';
208
+ if (reasoningText.length > REASONING_MAX_CHARS) {
209
+ const omitted = reasoningText.length - REASONING_MAX_CHARS;
210
+ reasoningText = reasoningText.slice(0, REASONING_MAX_CHARS);
211
+ truncatedCharsHint = `...(${omitted} 字省略)`;
212
+ }
213
+ const reasoningLines = reasoningText.length > 0 ? reasoningText.split('\n') : [];
214
+ const visibleReasoningLines = foldReasoning
215
+ ? reasoningLines.slice(0, 2)
216
+ : reasoningLines;
217
+ const hiddenReasoningCount = reasoningLines.length - visibleReasoningLines.length;
218
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [visibleReasoningLines.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [visibleReasoningLines.map((line, i) => (_jsx(Text, { color: "gray", dimColor: true, children: i === 0 ? `💭 ${line}` : ` ${line}` }, `r-${i}`))), hiddenReasoningCount > 0 && (_jsx(Text, { color: "gray", dimColor: true, children: ` ▾ ${hiddenReasoningCount} 行 reasoning 折叠(终端高度 < ${NARROW_TERMINAL_ROWS})` })), truncatedCharsHint.length > 0 && (_jsx(Text, { color: "gray", dimColor: true, children: ` ${truncatedCharsHint}` }))] })), text.length > 0 &&
219
+ text.split('\n').map((line, i) => i === 0 ? (_jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: '⏺ ' }), _jsx(Text, { children: line.length > 0 ? line : ' ' })] }, `a-${i}`)) : (_jsx(Text, { children: ` ${line}` }, `a-${i}`))), tcCount > 0 && (_jsx(Text, { color: "yellow", dimColor: true, children: ` └─ 调用了 ${tcCount} 个工具:${(message.tool_calls ?? [])
28
220
  .map((tc) => tc.function.name)
29
221
  .join(', ')}` }))] }));
30
222
  }
@@ -32,7 +224,8 @@ function MessageRow({ message }) {
32
224
  const lines = message.content.split('\n');
33
225
  const preview = lines.slice(0, MAX_TOOL_PREVIEW_LINES).join('\n');
34
226
  const hidden = Math.max(0, lines.length - MAX_TOOL_PREVIEW_LINES);
35
- return (_jsx(Box, { marginBottom: 1, paddingLeft: 2, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", dimColor: true, children: "[tool result]" }), _jsx(Text, { color: "gray", children: preview }), hidden > 0 && (_jsx(Text, { color: "gray", dimColor: true, children: `... (省略 ${hidden} 行)` }))] }) }));
227
+ const shortId = message.tool_call_id.slice(-8);
228
+ return (_jsx(Box, { marginBottom: 1, paddingLeft: 2, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", dimColor: true, children: `[tool result · #${shortId}]` }), _jsx(Text, { color: "gray", children: preview }), hidden > 0 && (_jsx(Text, { color: "gray", dimColor: true, children: `... ▾ 完整 ${lines.length} 行(输入 /show ${shortId} 查看)` }))] }) }));
36
229
  }
37
230
  }
38
231
  }
@@ -1,43 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * ============================================================
4
- * src/ui/ProgressPanel.tsx —— 工作过程显示面板(v2 瘦身版)
5
- * ------------------------------------------------------------
6
- * 贴在 InputBox 上方的一块"过程区"。
7
- * 仅当 progressStage !== 'idle' 时渲染,turn 结束后整块消失。
8
- *
9
- * v2 变化:
10
- * - 删除 streamingText 块(搬去 StreamingBlock 独立组件)
11
- * - 删除 reasoning preview 块(reasoning 留在 history 由 MessageList 渲染)
12
- * - spinner 行融合 friendly 工具描述:
13
- * `⠋ Reading src/foo.ts +2 more · 2.3s · ~890 tokens · step 6`
14
- * - 不再单独列每个 tool 一行(多个工具用 +N more 在 spinner 行末尾)
15
- *
16
- * 样式:
17
- * ⠋ Reading src/foo.ts +2 more · 2.3s · ~890 tokens · step 6
18
- * [plugin:ralph-wiggum] 3/10 验证中...
19
- *
20
- * 设计要点:
21
- * - braille spinner 用 useEffect + setInterval(120ms) 自驱动;unmount 时 clearInterval
22
- * - 最终高度:1 行 spinner / 0~1 行 plugin progress
23
- * ============================================================
24
- */
25
- import React from 'react';
26
2
  import { Box, Text } from 'ink';
27
- /** braille spinner 帧序列 */
28
- const SPINNER_FRAMES = [
29
- '⠋',
30
- '⠙',
31
- '⠹',
32
- '⠸',
33
- '⠼',
34
- '⠴',
35
- '⠦',
36
- '⠧',
37
- '⠇',
38
- '⠏',
39
- ];
40
- const SPINNER_INTERVAL_MS = 120;
41
3
  /** 阶段标签中文映射 */
42
4
  const STAGE_LABELS = {
43
5
  thinking: '思考中',
@@ -54,18 +16,12 @@ function truncateTail(s, max) {
54
16
  return `${s.slice(0, max - 1)}…`;
55
17
  }
56
18
  /**
57
- * 自驱动的 braille spinner hook。
58
- * 组件 unmount 时自动 clearInterval,避免悬挂定时器。
19
+ * 静态指示符:不使用动画 spinner(setState+setInterval 会每 120ms 触发整棵树 rerender,
20
+ * 导致 StreamingBlock / InputBox 等兄弟组件频繁重绘 → 终端追加新行而非原地刷新)。
21
+ * 用固定字符代替,stage_change 事件本身已足够表达"正在工作"。
59
22
  */
60
23
  function useSpinnerFrame() {
61
- const [frame, setFrame] = React.useState(0);
62
- React.useEffect(() => {
63
- const t = setInterval(() => {
64
- setFrame((f) => (f + 1) % SPINNER_FRAMES.length);
65
- }, SPINNER_INTERVAL_MS);
66
- return () => clearInterval(t);
67
- }, []);
68
- return SPINNER_FRAMES[frame];
24
+ return '◉';
69
25
  }
70
26
  export function ProgressPanel({ progressStage, runningTools, turnTokens, turnElapsedMs, turnStep, pluginProgress, }) {
71
27
  // spinner hook 必须无条件调用(React Hooks 规则),早 return 放在 hook 之后
@@ -1,16 +1,35 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * ============================================================
4
+ * src/ui/StatusLine.tsx —— 底部状态栏
5
+ * ------------------------------------------------------------
6
+ * 显示:
7
+ * cwd ~/proj · {provider}/{model} · tokens 12.3K / 200K · 距压缩 7.7K
8
+ * 接近压缩阈值时 tokens / 距压缩 变黄;超过阈值时变红。
9
+ * ============================================================
10
+ */
11
+ import React from 'react';
2
12
  import { Box, Text } from 'ink';
3
13
  import { homedir } from 'node:os';
4
14
  import { sep } from 'node:path';
5
15
  import { getWorkingDir } from '../bootstrap/workingDir.js';
6
16
  import { useTokenUsage } from './hooks/useTokenUsage.js';
7
- export function StatusLine({ provider, history, pluginProgress, }) {
17
+ /**
18
+ * React.memo 包装。
19
+ *
20
+ * props: provider 引用启动后稳定;history 引用变化(useMemo slice)但内容变化
21
+ * 才需要重算 useTokenUsage —— React.memo 浅比较 history 引用就够了(slice 后引用
22
+ * 变了说明真有新消息);pluginProgress 仅插件循环时更新。
23
+ *
24
+ * 流式 streamingText 高频更新不影响 history 引用,StatusLine 不会被带着 rerender。
25
+ */
26
+ export const StatusLine = React.memo(function StatusLine({ provider, history, pluginProgress, }) {
8
27
  const usage = useTokenUsage(history, provider);
9
28
  const ratio = usage.tokens / usage.threshold;
10
29
  const color = ratio >= 1 ? 'red' : ratio >= 0.7 ? 'yellow' : 'green';
11
30
  const cwdDisplay = shortenPath(getWorkingDir());
12
31
  return (_jsxs(Box, { children: [pluginProgress && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "magenta", children: ["\uD83D\uDD04 ", pluginProgress.pluginId, " "] }), _jsxs(Text, { color: "magenta", children: [pluginProgress.current, "/", pluginProgress.max] }), _jsx(Text, { color: "gray", children: ' · ' })] })), _jsx(Text, { color: "cyan", children: "cwd " }), _jsx(Text, { color: "gray", children: cwdDisplay }), _jsx(Text, { color: "gray", children: ' · ' }), _jsxs(Text, { color: "gray", children: [provider.name, "/", provider.model] }), _jsx(Text, { color: "gray", children: ' · ' }), _jsxs(Text, { color: color, children: ["tokens ", fmt(usage.tokens), " / ", fmt(usage.contextWindow)] }), _jsx(Text, { color: "gray", children: ' · ' }), _jsx(Text, { color: color, children: usage.toThreshold > 0 ? `距压缩 ${fmt(usage.toThreshold)}` : `已超阈值(下轮压缩)` })] }));
13
- }
32
+ });
14
33
  /** 把整数格式化为 1.2K / 12.3K / 1.5M */
15
34
  function fmt(n) {
16
35
  if (n < 1000)