minimal-agent 0.5.3 → 0.5.5

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.
@@ -0,0 +1,98 @@
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
+ 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
+ /** 阶段标签中文映射 */
42
+ const STAGE_LABELS = {
43
+ thinking: '思考中',
44
+ streaming: '回答中',
45
+ tool_executing: '调用工具',
46
+ compacting: '压缩历史',
47
+ };
48
+ /** 工具参数预览单行最大宽度(fallback 时用) */
49
+ const TOOL_ARGS_MAX = 60;
50
+ /** 按字符宽度截断,超长尾部加 …(按字符而非 byte,够用) */
51
+ function truncateTail(s, max) {
52
+ if (s.length <= max)
53
+ return s;
54
+ return `${s.slice(0, max - 1)}…`;
55
+ }
56
+ /**
57
+ * 自驱动的 braille spinner hook。
58
+ * 组件 unmount 时自动 clearInterval,避免悬挂定时器。
59
+ */
60
+ 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];
69
+ }
70
+ export function ProgressPanel({ progressStage, runningTools, turnTokens, turnElapsedMs, turnStep, pluginProgress, }) {
71
+ // spinner hook 必须无条件调用(React Hooks 规则),早 return 放在 hook 之后
72
+ const spinner = useSpinnerFrame();
73
+ // idle 时整个面板不渲染
74
+ if (progressStage === 'idle') {
75
+ return null;
76
+ }
77
+ const stageLabel = STAGE_LABELS[progressStage];
78
+ const elapsedSec = (turnElapsedMs / 1000).toFixed(1);
79
+ const tokensStr = turnTokens.toLocaleString();
80
+ // 头行的"主描述":有工具用 friendly,无工具用 stage label
81
+ let mainDescription;
82
+ if (runningTools.length > 0) {
83
+ const first = runningTools[0];
84
+ mainDescription =
85
+ first.argsFriendly && first.argsFriendly.length > 0
86
+ ? first.argsFriendly
87
+ : `${first.toolName}(${truncateTail(first.argsPreview, TOOL_ARGS_MAX)})`;
88
+ if (runningTools.length > 1) {
89
+ mainDescription += ` +${runningTools.length - 1} more`;
90
+ }
91
+ }
92
+ else {
93
+ mainDescription = stageLabel;
94
+ }
95
+ // 头行尾部的附加信息:耗时 / token / step
96
+ const stepHint = typeof turnStep === 'number' && turnStep > 0 ? ` · step ${turnStep}` : '';
97
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", children: [spinner, " "] }), _jsx(Text, { bold: true, children: mainDescription }), _jsx(Text, { color: "gray", children: ` · ${elapsedSec}s · ~${tokensStr} tokens${stepHint}` })] }), pluginProgress && (_jsx(Box, { children: _jsx(Text, { color: "magenta", children: `[plugin:${pluginProgress.pluginId}] ${pluginProgress.current}/${pluginProgress.max ?? '?'}${pluginProgress.message ? ` ${pluginProgress.message}` : ''}` }) }))] }));
98
+ }
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /** streaming text 显示尾部行数上限(超出截尾,老内容滚出窗口) */
4
+ const STREAMING_TAIL_LINES = 20;
5
+ export function StreamingBlock({ progressStage, streamingText, }) {
6
+ if (progressStage !== 'streaming' || streamingText.length === 0) {
7
+ return null;
8
+ }
9
+ const lines = streamingText.split('\n').slice(-STREAMING_TAIL_LINES);
10
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [lines.map((line, i) => (_jsx(Text, { children: line }, i))), _jsx(Text, { color: "gray", dimColor: true, children: "\u258D" })] }));
11
+ }
@@ -2,10 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  export function ToolStatus({ status, compacting }) {
4
4
  if (compacting) {
5
- return (_jsx(Box, { children: _jsx(Text, { color: "magenta", bold: true, children: "\uD83D\uDDDC \u6B63\u5728\u538B\u7F29\u5BF9\u8BDD\u5386\u53F2..." }) }));
5
+ return (_jsx(Box, { children: _jsx(Text, { color: "magenta", bold: true, children: "\uD83D\uDF18 \u6B63\u5728\u538B\u7F29\u5BF9\u8BDD\u5386\u53F2..." }) }));
6
6
  }
7
7
  if (status) {
8
- return (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ['⏳ ', _jsx(Text, { bold: true, children: status.toolName }), `(${status.argsPreview})`] }) }));
8
+ return (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["\u23F3 ", _jsx(Text, { bold: true, children: status.toolName }), "(", status.argsPreview, ")"] }) }));
9
9
  }
10
10
  return null;
11
11
  }
@@ -29,6 +29,7 @@ export function useChat(args) {
29
29
  const [version, setVersion] = useState(0);
30
30
  const bump = useCallback(() => setVersion((v) => v + 1), []);
31
31
  const [streamingText, setStreamingText] = useState('');
32
+ const [streamingReasoning, setStreamingReasoning] = useState('');
32
33
  const [toolStatus, setToolStatus] = useState(null);
33
34
  const [isLoading, setIsLoading] = useState(false);
34
35
  const [error, setError] = useState(null);
@@ -55,6 +56,49 @@ export function useChat(args) {
55
56
  setPluginProgress(null);
56
57
  const ac = new AbortController();
57
58
  abortRef.current = ac;
59
+ let streamBuf = '';
60
+ let reasoningBuf = '';
61
+ let streamTimer = null;
62
+ const throttledSetStreamingText = (value) => {
63
+ streamBuf = typeof value === 'function' ? value(streamBuf) : value;
64
+ if (!streamTimer) {
65
+ streamTimer = setInterval(() => {
66
+ if (streamBuf.length > 0) {
67
+ setStreamingText(streamBuf);
68
+ }
69
+ if (reasoningBuf.length > 0) {
70
+ setStreamingReasoning(reasoningBuf);
71
+ }
72
+ }, 200);
73
+ }
74
+ };
75
+ const throttledSetStreamingReasoning = (value) => {
76
+ reasoningBuf = typeof value === 'function' ? value(reasoningBuf) : value;
77
+ if (!streamTimer) {
78
+ streamTimer = setInterval(() => {
79
+ if (streamBuf.length > 0) {
80
+ setStreamingText(streamBuf);
81
+ }
82
+ if (reasoningBuf.length > 0) {
83
+ setStreamingReasoning(reasoningBuf);
84
+ }
85
+ }, 200);
86
+ }
87
+ };
88
+ const flushStream = () => {
89
+ if (streamTimer) {
90
+ clearInterval(streamTimer);
91
+ streamTimer = null;
92
+ }
93
+ if (streamBuf.length > 0) {
94
+ setStreamingText(streamBuf);
95
+ streamBuf = '';
96
+ }
97
+ if (reasoningBuf.length > 0) {
98
+ setStreamingReasoning(reasoningBuf);
99
+ reasoningBuf = '';
100
+ }
101
+ };
58
102
  try {
59
103
  for await (const ev of runWithPlugins(trimmed, {
60
104
  provider: args.provider,
@@ -62,7 +106,8 @@ export function useChat(args) {
62
106
  signal: ac.signal,
63
107
  })) {
64
108
  handleEvent(ev, {
65
- setStreamingText,
109
+ setStreamingText: throttledSetStreamingText,
110
+ setStreamingReasoning: throttledSetStreamingReasoning,
66
111
  setToolStatus,
67
112
  setCompacting,
68
113
  setError,
@@ -76,8 +121,10 @@ export function useChat(args) {
76
121
  setError(`未捕获异常:${e.message}`);
77
122
  }
78
123
  finally {
124
+ flushStream();
79
125
  setIsLoading(false);
80
126
  setStreamingText('');
127
+ setStreamingReasoning('');
81
128
  setToolStatus(null);
82
129
  setCompacting(false);
83
130
  setPluginProgress(null);
@@ -104,6 +151,7 @@ export function useChat(args) {
104
151
  // 允许新 session 再次触发反应式压缩自救
105
152
  resetReactiveCompactState();
106
153
  setStreamingText('');
154
+ setStreamingReasoning('');
107
155
  setToolStatus(null);
108
156
  setError(null);
109
157
  setInterrupted(false);
@@ -156,6 +204,7 @@ export function useChat(args) {
156
204
  return {
157
205
  history,
158
206
  streamingText,
207
+ streamingReasoning,
159
208
  toolStatus,
160
209
  isLoading,
161
210
  error,
@@ -179,11 +228,16 @@ function handleEvent(ev, setters) {
179
228
  const e = ev;
180
229
  switch (e.type) {
181
230
  case 'text':
182
- setters.setStreamingText((prev) => prev + e.delta);
231
+ setters.setStreamingText(prev => prev + e.delta);
232
+ break;
233
+ case 'reasoning_delta':
234
+ if (e.delta) {
235
+ setters.setStreamingReasoning(prev => prev + e.delta);
236
+ }
183
237
  break;
184
238
  case 'assistant_message':
185
- // history 已被 runQuery 直接 push,这里只刷一下视图 + 清流式 buffer
186
- setters.setStreamingText(() => '');
239
+ setters.setStreamingText('');
240
+ setters.setStreamingReasoning('');
187
241
  setters.bump();
188
242
  break;
189
243
  case 'tool_start':
@@ -199,10 +253,8 @@ function handleEvent(ev, setters) {
199
253
  setters.bump();
200
254
  break;
201
255
  case 'compact_start':
202
- // reactive 自救场景:LLM 失败前可能已经 yield 过部分 text delta,
203
- // 压缩前清掉残留 streamingText,避免重发后新文本追加在旧 buffer 后面。
204
- // autoCompact 场景下 streamingText 本来就是空,这里 no-op。
205
- setters.setStreamingText(() => '');
256
+ setters.setStreamingText('');
257
+ setters.setStreamingReasoning('');
206
258
  setters.setCompacting(true);
207
259
  setters.bump();
208
260
  break;
@@ -215,10 +267,14 @@ function handleEvent(ev, setters) {
215
267
  break;
216
268
  case 'interrupted':
217
269
  setters.setInterrupted(true);
270
+ setters.setStreamingText('');
271
+ setters.setStreamingReasoning('');
218
272
  setters.bump();
219
273
  break;
220
274
  case 'error':
221
275
  setters.setError(e.error);
276
+ setters.setStreamingText('');
277
+ setters.setStreamingReasoning('');
222
278
  setters.bump();
223
279
  break;
224
280
  case 'plugin_progress':
@@ -0,0 +1,186 @@
1
+ /**
2
+ * ============================================================
3
+ * src/ui/hooks/useInputHistory.ts —— 输入历史 ring buffer + 持久化
4
+ * ------------------------------------------------------------
5
+ * - ring buffer 上限 50 条
6
+ * - 持久化 ~/.minimal-agent/input-history.txt(一行一条 UTF-8)
7
+ * 转义 `\\` → `\\\\`,`\n` → `\\n`,避免多行输入被换行符撕碎
8
+ * - API:push / prev / next / search / resetCursor
9
+ * - 核心逻辑抽到 createInputHistoryStore,方便测试 DI 注入 fs
10
+ * hook 自身只是 useRef + useEffect 包一下,渲染零依赖
11
+ * ============================================================
12
+ */
13
+ import { useEffect, useMemo, useRef } from 'react';
14
+ import { mkdir as fsMkdir, readFile as fsReadFile, writeFile as fsWriteFile } from 'node:fs/promises';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ const MAX_ITEMS = 50;
18
+ const WRITE_DEBOUNCE_MS = 500;
19
+ /* ------------------------------------------------------------ *
20
+ * 转义:换行 + 反斜杠
21
+ * ------------------------------------------------------------ */
22
+ export function encodeHistoryLine(text) {
23
+ return text.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
24
+ }
25
+ export function decodeHistoryLine(line) {
26
+ // 状态机:遇到 \ 看下一个字符决定 \\ → \ 或 \n → 真换行
27
+ let out = '';
28
+ let i = 0;
29
+ while (i < line.length) {
30
+ const ch = line[i];
31
+ if (ch === '\\' && i + 1 < line.length) {
32
+ const nxt = line[i + 1];
33
+ if (nxt === '\\') {
34
+ out += '\\';
35
+ i += 2;
36
+ continue;
37
+ }
38
+ if (nxt === 'n') {
39
+ out += '\n';
40
+ i += 2;
41
+ continue;
42
+ }
43
+ }
44
+ out += ch;
45
+ i += 1;
46
+ }
47
+ return out;
48
+ }
49
+ export function createInputHistoryStore(opts = {}) {
50
+ const fs = opts.fs ?? {
51
+ readFile: (p) => fsReadFile(p, 'utf8'),
52
+ writeFile: (p, d) => fsWriteFile(p, d, 'utf8'),
53
+ mkdir: (p, o) => fsMkdir(p, o).then(() => undefined),
54
+ };
55
+ const historyPath = opts.historyPath ?? join(homedir(), '.minimal-agent', 'input-history.txt');
56
+ const debounceMs = opts.writeDebounceMs ?? WRITE_DEBOUNCE_MS;
57
+ let buffer = [];
58
+ let cursor = 0;
59
+ let writeTimer = null;
60
+ let pendingWrite = null;
61
+ async function doWrite() {
62
+ try {
63
+ await fs.mkdir(dirname(historyPath), { recursive: true });
64
+ const data = buffer.map(encodeHistoryLine).join('\n');
65
+ await fs.writeFile(historyPath, data);
66
+ }
67
+ catch {
68
+ // 持久化失败不影响 UI;下次 push 还会再试
69
+ }
70
+ }
71
+ function scheduleWrite() {
72
+ if (debounceMs <= 0) {
73
+ pendingWrite = doWrite();
74
+ return;
75
+ }
76
+ if (writeTimer)
77
+ clearTimeout(writeTimer);
78
+ writeTimer = setTimeout(() => {
79
+ writeTimer = null;
80
+ pendingWrite = doWrite();
81
+ }, debounceMs);
82
+ }
83
+ return {
84
+ async load() {
85
+ try {
86
+ const raw = await fs.readFile(historyPath);
87
+ if (!raw)
88
+ return;
89
+ const lines = raw.split('\n').filter((l) => l.length > 0);
90
+ buffer = lines.slice(-MAX_ITEMS).map(decodeHistoryLine);
91
+ cursor = buffer.length;
92
+ }
93
+ catch {
94
+ // 文件不存在 / 读不出 → 用空 buffer 起步
95
+ }
96
+ },
97
+ push(text) {
98
+ if (!text)
99
+ return;
100
+ // 连续重复去重:跟最后一条相同就忽略
101
+ if (buffer.length > 0 && buffer[buffer.length - 1] === text) {
102
+ cursor = buffer.length;
103
+ return;
104
+ }
105
+ buffer.push(text);
106
+ while (buffer.length > MAX_ITEMS)
107
+ buffer.shift();
108
+ cursor = buffer.length;
109
+ scheduleWrite();
110
+ },
111
+ prev() {
112
+ if (buffer.length === 0)
113
+ return null;
114
+ if (cursor <= 0)
115
+ return null;
116
+ cursor -= 1;
117
+ return buffer[cursor] ?? null;
118
+ },
119
+ next() {
120
+ if (buffer.length === 0)
121
+ return null;
122
+ if (cursor >= buffer.length)
123
+ return null;
124
+ cursor += 1;
125
+ if (cursor >= buffer.length) {
126
+ // 已走到 "新输入" 位(buffer 之后),返 null 让 UI 清空
127
+ return null;
128
+ }
129
+ return buffer[cursor] ?? null;
130
+ },
131
+ search(query) {
132
+ if (!query)
133
+ return [];
134
+ const hits = [];
135
+ // 反向遍历,最近优先;同字符串只收一次
136
+ const seen = new Set();
137
+ for (let i = buffer.length - 1; i >= 0; i -= 1) {
138
+ const item = buffer[i];
139
+ if (item.includes(query) && !seen.has(item)) {
140
+ hits.push(item);
141
+ seen.add(item);
142
+ }
143
+ }
144
+ return hits;
145
+ },
146
+ resetCursor() {
147
+ cursor = buffer.length;
148
+ },
149
+ async flush() {
150
+ if (writeTimer) {
151
+ clearTimeout(writeTimer);
152
+ writeTimer = null;
153
+ pendingWrite = doWrite();
154
+ }
155
+ if (pendingWrite)
156
+ await pendingWrite;
157
+ },
158
+ _snapshot() {
159
+ return { buffer: [...buffer], cursor };
160
+ },
161
+ };
162
+ }
163
+ /* ------------------------------------------------------------ *
164
+ * React hook 门面:store 用 useRef 持有,启动时 useEffect 拉盘
165
+ * ------------------------------------------------------------ */
166
+ export function useInputHistory(opts) {
167
+ // 同一组件实例共享同一 store;options 变化不重建(输入历史 hook 不需要重建语义)
168
+ const storeRef = useRef(null);
169
+ if (storeRef.current === null) {
170
+ storeRef.current = createInputHistoryStore(opts);
171
+ }
172
+ useEffect(() => {
173
+ // 异步拉盘,不 await;UI 在拉盘前按 ↑ 只是空操作
174
+ storeRef.current?.load();
175
+ }, []);
176
+ // 暴露稳定门面,绑定到内部 store
177
+ return useMemo(() => {
178
+ return {
179
+ push: (t) => storeRef.current.push(t),
180
+ prev: () => storeRef.current.prev(),
181
+ next: () => storeRef.current.next(),
182
+ search: (q) => storeRef.current.search(q),
183
+ resetCursor: () => storeRef.current.resetCursor(),
184
+ };
185
+ }, []);
186
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ============================================================
3
+ * src/ui/hooks/useTerminalSize.ts —— 终端尺寸感知
4
+ * ------------------------------------------------------------
5
+ * 返回当前终端的 rows / cols;监听 process.stdout 'resize' 事件,
6
+ * resize 时刷新 state,触发依赖此 hook 的组件 re-render(如 MessageList
7
+ * 根据 rows < 28 决定是否折叠 reasoning)。
8
+ *
9
+ * fallback 值:rows=24, cols=80(POSIX 终端历史默认)。
10
+ * 非 TTY 场景(pipe / 测试环境)下 process.stdout.rows 可能 undefined,
11
+ * fallback 兜底保证 UI 不崩。
12
+ * ============================================================
13
+ */
14
+ import { useEffect, useState } from 'react';
15
+ function readSize() {
16
+ return {
17
+ rows: process.stdout.rows ?? 24,
18
+ cols: process.stdout.columns ?? 80,
19
+ };
20
+ }
21
+ export function useTerminalSize() {
22
+ const [size, setSize] = useState(readSize);
23
+ useEffect(() => {
24
+ const update = () => setSize(readSize());
25
+ process.stdout.on('resize', update);
26
+ return () => {
27
+ process.stdout.off('resize', update);
28
+ };
29
+ }, []);
30
+ return size;
31
+ }