minimal-agent 0.5.4 → 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.
- package/package.json +1 -1
- package/src/context/archive.js +117 -0
- package/src/context/persistContext.js +16 -0
- package/src/context/sessionPath.js +59 -4
- package/src/context/sessionRegistry.js +104 -0
- package/src/loop.js +201 -32
- package/src/ui/InputBox.js +142 -5
- package/src/ui/MessageList.js +12 -4
- package/src/ui/ProgressPanel.js +98 -0
- package/src/ui/StreamingBlock.js +11 -0
- package/src/ui/ToolStatus.js +2 -2
- package/src/ui/hooks/useInputHistory.js +186 -0
- package/src/ui/hooks/useTerminalSize.js +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "最小化 Agent 系统 —— 10 工具 + 插件系统 + workflow DSL + 自动压缩 + OpenAI 兼容 + Ink TUI;NodeNext + tsc 原地编译,dev 用 Bun .ts、install 用 Node .js(学习/教学用)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Bill Wang <leiwang0359@gmail.com>",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/context/archive.ts —— /new 自动归档历史对话
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 做的事:
|
|
6
|
+
* `/new` 清空上下文前,先把当前对话历史落盘到 archive 目录,
|
|
7
|
+
* 供后续 `/list-archive`、`/restore <id>` 查看 / 找回。
|
|
8
|
+
*
|
|
9
|
+
* 文件路径:
|
|
10
|
+
* <MINIMAL_AGENT_HOME ?? ~>/.minimal-agent/archive/<cwd-hash>/<ISO-timestamp>.json
|
|
11
|
+
* - cwd-hash:当前工作目录 sha1 前 12 位(避免不同项目 archive 串台)
|
|
12
|
+
* - timestamp:new Date().toISOString().replace(/:/g, '-')(Windows 不允许冒号)
|
|
13
|
+
*
|
|
14
|
+
* 存储格式:
|
|
15
|
+
* { updatedAt: number; messages: Message[] }
|
|
16
|
+
*
|
|
17
|
+
* 抉择:
|
|
18
|
+
* - 仅含 system 消息(空对话)跳过写入,返回空字符串
|
|
19
|
+
* - 失败由调用方 try/catch 吞掉,本模块不做 stderr(保持纯函数语义)
|
|
20
|
+
* - 损坏文件在 listArchives 中静默 skip
|
|
21
|
+
* - 通过 MINIMAL_AGENT_HOME 环境变量让测试切到临时目录,避免污染真实 ~/.minimal-agent
|
|
22
|
+
* ============================================================
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { getWorkingDir } from '../bootstrap/workingDir.js';
|
|
29
|
+
/** 解析 archive 根目录,支持 MINIMAL_AGENT_HOME 测试覆盖 */
|
|
30
|
+
function agentHome() {
|
|
31
|
+
return process.env.MINIMAL_AGENT_HOME ?? homedir();
|
|
32
|
+
}
|
|
33
|
+
function archiveDir(cwd) {
|
|
34
|
+
const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
35
|
+
return join(agentHome(), '.minimal-agent', 'archive', hash);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 归档当前 messages 到 archive 目录。
|
|
39
|
+
*
|
|
40
|
+
* @returns archive id(成功)或空字符串(无内容跳过)
|
|
41
|
+
*
|
|
42
|
+
* 行为:
|
|
43
|
+
* - messages 为空 / 仅含 system role → 跳过,返回 ''
|
|
44
|
+
* - 写入失败抛错(调用方负责吞)
|
|
45
|
+
*/
|
|
46
|
+
export async function archiveCurrent(messages) {
|
|
47
|
+
const nonSystem = messages.filter((m) => m.role !== 'system');
|
|
48
|
+
if (nonSystem.length === 0)
|
|
49
|
+
return '';
|
|
50
|
+
const dir = archiveDir(getWorkingDir());
|
|
51
|
+
await mkdir(dir, { recursive: true });
|
|
52
|
+
const id = new Date().toISOString().replace(/:/g, '-');
|
|
53
|
+
const path = join(dir, `${id}.json`);
|
|
54
|
+
await writeFile(path, JSON.stringify({ updatedAt: Date.now(), messages }), 'utf8');
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 列出当前 cwd 下所有归档,最新优先(按文件 mtime 倒序)。
|
|
59
|
+
*
|
|
60
|
+
* 损坏 / 无法解析的文件会被静默跳过——不影响其它条目展示。
|
|
61
|
+
* 目录不存在视作空列表。
|
|
62
|
+
*/
|
|
63
|
+
export async function listArchives() {
|
|
64
|
+
const dir = archiveDir(getWorkingDir());
|
|
65
|
+
let names;
|
|
66
|
+
try {
|
|
67
|
+
names = await readdir(dir);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
if (!name.endsWith('.json'))
|
|
75
|
+
continue;
|
|
76
|
+
const id = name.slice(0, -'.json'.length);
|
|
77
|
+
const fpath = join(dir, name);
|
|
78
|
+
try {
|
|
79
|
+
const st = await stat(fpath);
|
|
80
|
+
const raw = await readFile(fpath, 'utf8');
|
|
81
|
+
const data = JSON.parse(raw);
|
|
82
|
+
if (!Array.isArray(data.messages))
|
|
83
|
+
continue;
|
|
84
|
+
const firstUser = data.messages.find((m) => m.role === 'user');
|
|
85
|
+
const preview = firstUser && typeof firstUser.content === 'string'
|
|
86
|
+
? firstUser.content.replace(/\s+/g, ' ').slice(0, 80)
|
|
87
|
+
: '';
|
|
88
|
+
entries.push({
|
|
89
|
+
id,
|
|
90
|
+
updatedAt: st.mtimeMs,
|
|
91
|
+
msgCount: data.messages.length,
|
|
92
|
+
firstUserPreview: preview,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// 损坏文件静默 skip
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 按 id 恢复归档。
|
|
103
|
+
*
|
|
104
|
+
* @returns messages 数组(成功);找不到 / 损坏 / messages 非数组 → null
|
|
105
|
+
*/
|
|
106
|
+
export async function restoreArchive(id) {
|
|
107
|
+
const dir = archiveDir(getWorkingDir());
|
|
108
|
+
const path = join(dir, `${id}.json`);
|
|
109
|
+
try {
|
|
110
|
+
const raw = await readFile(path, 'utf8');
|
|
111
|
+
const data = JSON.parse(raw);
|
|
112
|
+
return Array.isArray(data.messages) ? data.messages : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { mkdir, readFile, readdir, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
26
26
|
import { dirname, join } from 'node:path';
|
|
27
27
|
import { getWorkingDir } from '../bootstrap/workingDir.js';
|
|
28
|
+
import { archiveCurrent } from './archive.js';
|
|
28
29
|
import { sessionFileFor } from './sessionPath.js';
|
|
29
30
|
/** 上下文文件路径(按工作目录隔离,可用环境变量覆盖) */
|
|
30
31
|
export function getContextPath() {
|
|
@@ -76,6 +77,21 @@ export async function saveContext(messages, file) {
|
|
|
76
77
|
*/
|
|
77
78
|
export async function clearContext(file) {
|
|
78
79
|
const target = file ?? getContextPath();
|
|
80
|
+
// 归档当前历史(仅含 system 的空对话会跳过);失败不阻塞 clear
|
|
81
|
+
// 显式传 file(HTTP 模式 / 测试覆盖路径)或 MINIMAL_AGENT_CONTEXT_FILE 激活时
|
|
82
|
+
// 跳过 archive:这两种语义下 caller 自管路径,不应污染默认 archive 目录
|
|
83
|
+
const skipArchive = file !== undefined || !!process.env.MINIMAL_AGENT_CONTEXT_FILE;
|
|
84
|
+
if (!skipArchive) {
|
|
85
|
+
try {
|
|
86
|
+
const current = await loadContext(target);
|
|
87
|
+
if (current && current.length > 0) {
|
|
88
|
+
await archiveCurrent(current);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
process.stderr.write(`[minimal-agent] archive 失败(继续 clear):${e.message}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
79
95
|
try {
|
|
80
96
|
await unlink(target);
|
|
81
97
|
}
|
|
@@ -6,10 +6,16 @@
|
|
|
6
6
|
* 把当前工作目录编码为 ~/.minimal-agent/sessions/<encoded>.json
|
|
7
7
|
* 的文件名,让不同项目目录的对话历史互不覆盖。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* <sanitized-path>-<6-hex-hash>.json
|
|
9
|
+
* 命名规则(v2 加 sessionId 后):
|
|
10
|
+
* <sanitized-path>-<6-hex-hash>--<sessionId>.json
|
|
11
11
|
* sanitized:路径归一化后只保留 [a-z0-9-],最长 80 字符
|
|
12
12
|
* hash :原始路径 sha1 前 6 位,防 sanitize 后碰撞
|
|
13
|
+
* sessionId:v2 多 session 支持;默认 'default'
|
|
14
|
+
*
|
|
15
|
+
* 向后兼容:sessionId='default' 时,新文件名 `<sanitized>-<hash>--default.json`
|
|
16
|
+
* 与旧文件名 `<sanitized>-<hash>.json` 都可能存在。resolveSessionFile()
|
|
17
|
+
* 优先返新路径,新路径不存在但旧路径存在时回退到旧路径,保证老用户的
|
|
18
|
+
* last-context 无缝继承。
|
|
13
19
|
*
|
|
14
20
|
* 迁移:
|
|
15
21
|
* 旧版 ~/.minimal-agent/last-context.json 启动时一次性 rename 到
|
|
@@ -20,8 +26,29 @@ import { createHash } from 'node:crypto';
|
|
|
20
26
|
import { mkdir, rename, stat } from 'node:fs/promises';
|
|
21
27
|
import { homedir } from 'node:os';
|
|
22
28
|
import { dirname, join, resolve } from 'node:path';
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
const DEFAULT_SESSION_ID = 'default';
|
|
30
|
+
/**
|
|
31
|
+
* 把 cwd + sessionId 编码成会话文件绝对路径。
|
|
32
|
+
*
|
|
33
|
+
* @param cwd 工作目录
|
|
34
|
+
* @param sessionId 多 session 支持的 id;缺省 'default'
|
|
35
|
+
*
|
|
36
|
+
* 注意:'default' 调用走新格式 `<base>--default.json`。如果磁盘上只有
|
|
37
|
+
* 旧格式 `<base>.json`(v1 历史文件),需要用 resolveSessionFile()
|
|
38
|
+
* 拿到实际存在的路径用于 load。
|
|
39
|
+
*/
|
|
40
|
+
export function sessionFileFor(cwd, sessionId = DEFAULT_SESSION_ID) {
|
|
41
|
+
const normalized = resolve(cwd).replace(/\\/g, '/').toLowerCase();
|
|
42
|
+
const sanitized = normalized
|
|
43
|
+
.replace(/:/g, '')
|
|
44
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
45
|
+
.replace(/^-+|-+$/g, '')
|
|
46
|
+
.slice(0, 80);
|
|
47
|
+
const hash = createHash('sha1').update(normalized).digest('hex').slice(0, 6);
|
|
48
|
+
return join(homedir(), '.minimal-agent', 'sessions', `${sanitized}-${hash}--${sessionId}.json`);
|
|
49
|
+
}
|
|
50
|
+
/** 旧格式(v1,不带 `--<id>` 后缀)—— 仅 'default' session 可能存在 */
|
|
51
|
+
function legacySessionFileFor(cwd) {
|
|
25
52
|
const normalized = resolve(cwd).replace(/\\/g, '/').toLowerCase();
|
|
26
53
|
const sanitized = normalized
|
|
27
54
|
.replace(/:/g, '')
|
|
@@ -31,6 +58,34 @@ export function sessionFileFor(cwd) {
|
|
|
31
58
|
const hash = createHash('sha1').update(normalized).digest('hex').slice(0, 6);
|
|
32
59
|
return join(homedir(), '.minimal-agent', 'sessions', `${sanitized}-${hash}.json`);
|
|
33
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* 解析 cwd + sessionId 对应的实际存在的会话文件路径。
|
|
63
|
+
*
|
|
64
|
+
* - 新格式(带 `--<id>`)存在 → 返回新路径
|
|
65
|
+
* - 仅 sessionId='default' 且新路径不存在但旧路径(不带 `--`)存在 → 返回旧路径
|
|
66
|
+
* - 都不存在 → 返回新路径(让调用方按新格式 create)
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveSessionFile(cwd, sessionId = DEFAULT_SESSION_ID) {
|
|
69
|
+
const fresh = sessionFileFor(cwd, sessionId);
|
|
70
|
+
try {
|
|
71
|
+
await stat(fresh);
|
|
72
|
+
return fresh;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// 新路径不存在
|
|
76
|
+
}
|
|
77
|
+
if (sessionId === DEFAULT_SESSION_ID) {
|
|
78
|
+
const legacy = legacySessionFileFor(cwd);
|
|
79
|
+
try {
|
|
80
|
+
await stat(legacy);
|
|
81
|
+
return legacy;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// 旧路径也不存在
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return fresh;
|
|
88
|
+
}
|
|
34
89
|
/**
|
|
35
90
|
* 一次性把旧版 ~/.minimal-agent/last-context.json 迁到当前 cwd 对应的
|
|
36
91
|
* 会话文件。
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/context/sessionRegistry.ts —— 同 cwd 多 session 注册表
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 做的事:
|
|
6
|
+
* 一个工作目录下可以有多个并行 session(id = 任意字符串),
|
|
7
|
+
* 每次只激活一个。active 信息存 ~/.minimal-agent/active-sessions.json,
|
|
8
|
+
* 键是 cwd 的 sha1[:12],值是当前激活的 session id 字符串。
|
|
9
|
+
*
|
|
10
|
+
* 文件路径:
|
|
11
|
+
* <MINIMAL_AGENT_HOME ?? ~>/.minimal-agent/active-sessions.json
|
|
12
|
+
* <MINIMAL_AGENT_HOME ?? ~>/.minimal-agent/sessions/<cwd-hash>--<id>.json
|
|
13
|
+
* (新格式;旧格式 <cwd-hash>.json 视作 id='default' 兼容)
|
|
14
|
+
*
|
|
15
|
+
* 抉择:
|
|
16
|
+
* - active 文件 atomic write(写 .tmp + rename)—— 防止跨进程并发写时部分写入
|
|
17
|
+
* - sessionId 为字符串,'default' 是约定的默认值(无 active 时返回它)
|
|
18
|
+
* - listSessions 同时扫新旧格式,保证迁移期不丢
|
|
19
|
+
* ============================================================
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
/** 解析根目录,支持 MINIMAL_AGENT_HOME 测试覆盖 */
|
|
26
|
+
function agentHome() {
|
|
27
|
+
return process.env.MINIMAL_AGENT_HOME ?? homedir();
|
|
28
|
+
}
|
|
29
|
+
function agentDir() {
|
|
30
|
+
return join(agentHome(), '.minimal-agent');
|
|
31
|
+
}
|
|
32
|
+
function activeFilePath() {
|
|
33
|
+
return join(agentDir(), 'active-sessions.json');
|
|
34
|
+
}
|
|
35
|
+
function sessionsDirPath() {
|
|
36
|
+
return join(agentDir(), 'sessions');
|
|
37
|
+
}
|
|
38
|
+
function cwdHash(cwd) {
|
|
39
|
+
return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
40
|
+
}
|
|
41
|
+
async function loadActiveMap() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readFile(activeFilePath(), 'utf8');
|
|
44
|
+
const data = JSON.parse(raw);
|
|
45
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 获取当前 cwd 的激活 session id。
|
|
56
|
+
* 无记录 → 返回 'default'。
|
|
57
|
+
*/
|
|
58
|
+
export async function getActiveSession(cwd) {
|
|
59
|
+
const map = await loadActiveMap();
|
|
60
|
+
return map[cwdHash(cwd)] ?? 'default';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 设置当前 cwd 的激活 session id。
|
|
64
|
+
*
|
|
65
|
+
* 写入策略:先写 <target>.tmp,再原子 rename 到 target;
|
|
66
|
+
* 即使被中断也只会留临时文件,不破坏 target。
|
|
67
|
+
*/
|
|
68
|
+
export async function setActiveSession(cwd, sessionId) {
|
|
69
|
+
const map = await loadActiveMap();
|
|
70
|
+
map[cwdHash(cwd)] = sessionId;
|
|
71
|
+
await mkdir(agentDir(), { recursive: true });
|
|
72
|
+
const target = activeFilePath();
|
|
73
|
+
const tmp = `${target}.tmp`;
|
|
74
|
+
await writeFile(tmp, JSON.stringify(map), 'utf8');
|
|
75
|
+
await rename(tmp, target);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 列出当前 cwd 下所有 session id(含 'default')。
|
|
79
|
+
*
|
|
80
|
+
* 扫描规则:
|
|
81
|
+
* - 新格式 `<hash>--<id>.json` → 提取 id
|
|
82
|
+
* - 旧格式 `<hash>.json` → 视作 'default'
|
|
83
|
+
* - 都没有 → 返回 ['default'](让 UI 默认能 list 出至少一项)
|
|
84
|
+
*/
|
|
85
|
+
export async function listSessions(cwd) {
|
|
86
|
+
const dir = sessionsDirPath();
|
|
87
|
+
const hash = cwdHash(cwd);
|
|
88
|
+
let names;
|
|
89
|
+
try {
|
|
90
|
+
names = await readdir(dir);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return ['default'];
|
|
94
|
+
}
|
|
95
|
+
const prefix = `${hash}--`;
|
|
96
|
+
const ids = names
|
|
97
|
+
.filter((n) => n.startsWith(prefix) && n.endsWith('.json'))
|
|
98
|
+
.map((n) => n.slice(prefix.length, -'.json'.length));
|
|
99
|
+
const legacy = `${hash}.json`;
|
|
100
|
+
if (names.includes(legacy) && !ids.includes('default')) {
|
|
101
|
+
ids.unshift('default');
|
|
102
|
+
}
|
|
103
|
+
return ids.length > 0 ? ids : ['default'];
|
|
104
|
+
}
|
package/src/loop.js
CHANGED
|
@@ -15,18 +15,23 @@
|
|
|
15
15
|
* a. 自动压缩检查(防止 context 撑爆)
|
|
16
16
|
* b. 调 LLM,把流式响应组装成完整的 assistant message
|
|
17
17
|
* c. 如果 assistant 不调工具 → 结束,return
|
|
18
|
-
* d.
|
|
18
|
+
* d. 否则并行执行所有工具调用(OpenAI 协议要求 tool 消息按 tool_calls 顺序回填)
|
|
19
19
|
* e. 进入下一轮(让模型看到 tool_result 后继续推理)
|
|
20
20
|
*
|
|
21
21
|
* 失控保护:maxTurns(默认 50)。
|
|
22
22
|
* 中断支持:AbortSignal 透传到 chat() 和 tool.call()。
|
|
23
|
+
*
|
|
24
|
+
* v0.5.3 新增"工作过程显示"事件:
|
|
25
|
+
* - stage_change:thinking / streaming / tool_executing / compacting
|
|
26
|
+
* - reasoning:思维链流式增量(仅字符串型 field)
|
|
27
|
+
* - token_tick:200ms 节流,UI 用来显示 token / 耗时心跳
|
|
23
28
|
* ============================================================
|
|
24
29
|
*/
|
|
25
30
|
import { autoCompactIfNeeded } from './context/compact.js';
|
|
26
31
|
import { microCompact, incrementTurn, expireOldEntries } from './context/microCompactLite.js';
|
|
27
32
|
import { isPromptTooLongError, reactiveCompactIfApplicable, } from './context/reactiveCompact.js';
|
|
28
|
-
import { chat } from './llm/client.js';
|
|
29
|
-
import { ALL_TOOLS, executeTool } from './tools/index.js';
|
|
33
|
+
import { chat as defaultChat } from './llm/client.js';
|
|
34
|
+
import { ALL_TOOLS, executeTool as defaultExecuteTool } from './tools/index.js';
|
|
30
35
|
/**
|
|
31
36
|
* 执行一次"用户输入 → 模型回答完成"的完整流程。
|
|
32
37
|
*
|
|
@@ -35,11 +40,20 @@ import { ALL_TOOLS, executeTool } from './tools/index.js';
|
|
|
35
40
|
export async function* runQuery(userInput, options) {
|
|
36
41
|
const { provider, history, signal, sessionState } = options;
|
|
37
42
|
const maxTurns = options.maxTurns ?? 50;
|
|
38
|
-
//
|
|
39
|
-
|
|
43
|
+
// DI fallback:测试可传 stub,生产走真实模块单例
|
|
44
|
+
const chatFn = options.chat ?? defaultChat;
|
|
45
|
+
const executeToolFn = options.executeTool ?? defaultExecuteTool;
|
|
46
|
+
// 1. 用户消息入栈(v2:填 timestamp,让 MessageList 的 turn 边界横线
|
|
47
|
+
// 能拿到首条 user 的真实时间,否则 fallback Date.now() 会丢失"提交时刻"语义)
|
|
48
|
+
history.push({ role: 'user', content: userInput, timestamp: Date.now() });
|
|
40
49
|
// 反应式压缩自救:本 runQuery 只允许触发一次,避免压缩失败导致死循环。
|
|
41
50
|
// 配合 reactiveCompact.ts 的 attempted(session 级)双层防爆。
|
|
42
51
|
let reactiveAttempted = false;
|
|
52
|
+
// token_tick 心跳起点:跨多轮累计估算 token 与耗时
|
|
53
|
+
const turnStart = Date.now();
|
|
54
|
+
// v2 工具 step 计数器:跨整次 runQuery 累计每个工具 worker 的派发序号
|
|
55
|
+
// (每个 worker 启动时 ++)。token_tick yield 时携带 stepN 给 UI 显示 "step N"。
|
|
56
|
+
let stepN = 0;
|
|
43
57
|
let turn = 0;
|
|
44
58
|
while (turn < maxTurns) {
|
|
45
59
|
turn++;
|
|
@@ -50,6 +64,7 @@ export async function* runQuery(userInput, options) {
|
|
|
50
64
|
history.push({
|
|
51
65
|
role: 'user',
|
|
52
66
|
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
67
|
+
timestamp: Date.now(),
|
|
53
68
|
});
|
|
54
69
|
yield { type: 'error', error: '已被用户中断' };
|
|
55
70
|
return;
|
|
@@ -59,10 +74,13 @@ export async function* runQuery(userInput, options) {
|
|
|
59
74
|
const compact = await autoCompactIfNeeded(history, provider);
|
|
60
75
|
if (compact.compacted) {
|
|
61
76
|
yield { type: 'compact_start' };
|
|
77
|
+
yield { type: 'stage_change', stage: 'compacting' };
|
|
62
78
|
// in-place 替换 history(保持调用方持有的引用有效)
|
|
63
79
|
history.length = 0;
|
|
64
80
|
history.push(...compact.messages);
|
|
65
81
|
yield { type: 'compact_done', before: compact.before, after: compact.after };
|
|
82
|
+
// 压缩完会回到 LLM 调用,stage 退回 thinking
|
|
83
|
+
yield { type: 'stage_change', stage: 'thinking' };
|
|
66
84
|
}
|
|
67
85
|
}
|
|
68
86
|
catch (e) {
|
|
@@ -83,14 +101,24 @@ export async function* runQuery(userInput, options) {
|
|
|
83
101
|
let reasoningContent = '';
|
|
84
102
|
let reasoningString = '';
|
|
85
103
|
const reasoningDetails = [];
|
|
104
|
+
/** 首个 text_delta 时切到 streaming,再来不重复 yield */
|
|
105
|
+
let stageStreamingYielded = false;
|
|
106
|
+
/** 200ms token_tick 节流游标 */
|
|
107
|
+
let lastTick = Date.now();
|
|
108
|
+
// 进入 LLM 流前:等首 token / reasoning,本质是 thinking
|
|
109
|
+
yield { type: 'stage_change', stage: 'thinking' };
|
|
86
110
|
try {
|
|
87
|
-
for await (const ev of
|
|
111
|
+
for await (const ev of chatFn({
|
|
88
112
|
provider,
|
|
89
113
|
messages: history,
|
|
90
114
|
tools: ALL_TOOLS,
|
|
91
115
|
signal,
|
|
92
116
|
})) {
|
|
93
117
|
if (ev.type === 'text_delta') {
|
|
118
|
+
if (!stageStreamingYielded) {
|
|
119
|
+
stageStreamingYielded = true;
|
|
120
|
+
yield { type: 'stage_change', stage: 'streaming' };
|
|
121
|
+
}
|
|
94
122
|
assistantText += ev.delta;
|
|
95
123
|
yield { type: 'text', delta: ev.delta };
|
|
96
124
|
}
|
|
@@ -100,11 +128,14 @@ export async function* runQuery(userInput, options) {
|
|
|
100
128
|
else if (ev.type === 'reasoning_delta') {
|
|
101
129
|
if (ev.field === 'reasoning_content' && ev.delta) {
|
|
102
130
|
reasoningContent += ev.delta;
|
|
131
|
+
yield { type: 'reasoning', delta: ev.delta };
|
|
103
132
|
}
|
|
104
133
|
else if (ev.field === 'reasoning' && ev.delta) {
|
|
105
134
|
reasoningString += ev.delta;
|
|
135
|
+
yield { type: 'reasoning', delta: ev.delta };
|
|
106
136
|
}
|
|
107
137
|
else if (ev.field === 'reasoning_details' && ev.items) {
|
|
138
|
+
// reasoning_details 是结构化数组,没有 delta 字符串,跳过 yield
|
|
108
139
|
reasoningDetails.push(...ev.items);
|
|
109
140
|
}
|
|
110
141
|
if (ev.delta) {
|
|
@@ -112,6 +143,20 @@ export async function* runQuery(userInput, options) {
|
|
|
112
143
|
}
|
|
113
144
|
}
|
|
114
145
|
// ev.type === 'done' 时无需处理:循环自然结束
|
|
146
|
+
// 每 chunk 同步检查 200ms 节流,避免起 setInterval 与 generator yield 时机冲突
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (now - lastTick >= 200) {
|
|
149
|
+
lastTick = now;
|
|
150
|
+
const usedThisTurn = Math.ceil(assistantText.length / 4) +
|
|
151
|
+
Math.ceil(reasoningContent.length / 4) +
|
|
152
|
+
Math.ceil(reasoningString.length / 4);
|
|
153
|
+
yield {
|
|
154
|
+
type: 'token_tick',
|
|
155
|
+
usedThisTurn,
|
|
156
|
+
elapsedMs: now - turnStart,
|
|
157
|
+
stepN,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
115
160
|
}
|
|
116
161
|
}
|
|
117
162
|
catch (e) {
|
|
@@ -120,6 +165,7 @@ export async function* runQuery(userInput, options) {
|
|
|
120
165
|
history.push({
|
|
121
166
|
role: 'user',
|
|
122
167
|
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
168
|
+
timestamp: Date.now(),
|
|
123
169
|
});
|
|
124
170
|
yield { type: 'interrupted' };
|
|
125
171
|
return;
|
|
@@ -130,6 +176,7 @@ export async function* runQuery(userInput, options) {
|
|
|
130
176
|
if (isPromptTooLongError(e) && !reactiveAttempted) {
|
|
131
177
|
reactiveAttempted = true;
|
|
132
178
|
yield { type: 'compact_start' };
|
|
179
|
+
yield { type: 'stage_change', stage: 'compacting' };
|
|
133
180
|
const result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive);
|
|
134
181
|
if (result.recovered) {
|
|
135
182
|
history.length = 0;
|
|
@@ -139,6 +186,8 @@ export async function* runQuery(userInput, options) {
|
|
|
139
186
|
before: result.before ?? 0,
|
|
140
187
|
after: result.after ?? 0,
|
|
141
188
|
};
|
|
189
|
+
// 压缩完回到 LLM 调用
|
|
190
|
+
yield { type: 'stage_change', stage: 'thinking' };
|
|
142
191
|
turn--; // 不消耗 turn 配额,本轮重新走
|
|
143
192
|
continue;
|
|
144
193
|
}
|
|
@@ -154,6 +203,7 @@ export async function* runQuery(userInput, options) {
|
|
|
154
203
|
...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
|
|
155
204
|
...(reasoningString ? { reasoning: reasoningString } : {}),
|
|
156
205
|
...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}),
|
|
206
|
+
timestamp: Date.now(),
|
|
157
207
|
};
|
|
158
208
|
history.push(assistantMsg);
|
|
159
209
|
yield { type: 'assistant_message', message: assistantMsg };
|
|
@@ -162,41 +212,116 @@ export async function* runQuery(userInput, options) {
|
|
|
162
212
|
yield { type: 'turn_done' };
|
|
163
213
|
return;
|
|
164
214
|
}
|
|
165
|
-
// 5.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
215
|
+
// 5. 并行执行所有工具
|
|
216
|
+
//
|
|
217
|
+
// 设计:用 Promise.allSettled 启动 N 个 worker,配合一个简易事件队列
|
|
218
|
+
// (queue + signalNew)把多个 worker 的 tool_start / tool_end 事件按
|
|
219
|
+
// 真实完成顺序交错 yield 给 UI;但 history 里的 tool 消息严格按
|
|
220
|
+
// toolCallsByIndex 索引顺序 push(OpenAI 协议要求 tool 消息和上一条
|
|
221
|
+
// assistant 的 tool_calls 一一对应)。
|
|
222
|
+
yield { type: 'stage_change', stage: 'tool_executing' };
|
|
223
|
+
const queue = [];
|
|
224
|
+
let signalNew = null;
|
|
225
|
+
const enqueue = (ev) => {
|
|
226
|
+
queue.push(ev);
|
|
227
|
+
signalNew?.();
|
|
228
|
+
signalNew = null;
|
|
229
|
+
};
|
|
230
|
+
const workers = toolCallsByIndex.map(async (tc) => {
|
|
231
|
+
// 每个 worker 启动时累加 step 序号;token_tick 携带它给 UI 显示进度感
|
|
232
|
+
stepN++;
|
|
233
|
+
enqueue({
|
|
234
|
+
type: 'tool_start',
|
|
235
|
+
toolCallId: tc.id,
|
|
236
|
+
toolName: tc.function.name,
|
|
237
|
+
argsPreview: previewArgs(tc.function.arguments),
|
|
238
|
+
argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
const result = await executeToolFn(tc.function.name, tc.function.arguments, signal);
|
|
242
|
+
enqueue({
|
|
243
|
+
type: 'tool_end',
|
|
244
|
+
toolCallId: tc.id,
|
|
245
|
+
toolName: tc.function.name,
|
|
246
|
+
ok: result.ok,
|
|
247
|
+
content: result.ok ? result.content : `Error: ${result.error}`,
|
|
248
|
+
});
|
|
249
|
+
return { tc, result };
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
if (e.name === 'AbortError') {
|
|
253
|
+
enqueue({
|
|
254
|
+
type: 'tool_end',
|
|
255
|
+
toolCallId: tc.id,
|
|
256
|
+
toolName: tc.function.name,
|
|
257
|
+
ok: false,
|
|
258
|
+
content: '(已中断)',
|
|
259
|
+
});
|
|
260
|
+
return { tc, result: { ok: false, error: 'aborted' } };
|
|
261
|
+
}
|
|
262
|
+
const msg = e.message;
|
|
263
|
+
enqueue({
|
|
264
|
+
type: 'tool_end',
|
|
265
|
+
toolCallId: tc.id,
|
|
266
|
+
toolName: tc.function.name,
|
|
267
|
+
ok: false,
|
|
268
|
+
content: msg,
|
|
269
|
+
});
|
|
270
|
+
return { tc, result: { ok: false, error: msg } };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
const allDone = Promise.allSettled(workers);
|
|
274
|
+
let finished = false;
|
|
275
|
+
allDone.then(() => {
|
|
276
|
+
finished = true;
|
|
277
|
+
signalNew?.();
|
|
278
|
+
signalNew = null;
|
|
279
|
+
});
|
|
280
|
+
while (!finished || queue.length > 0) {
|
|
281
|
+
while (queue.length > 0)
|
|
282
|
+
yield queue.shift();
|
|
283
|
+
if (finished)
|
|
284
|
+
break;
|
|
285
|
+
await new Promise((r) => {
|
|
286
|
+
signalNew = r;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// 严格按 toolCallsByIndex 顺序 push tool 消息进 history(OpenAI 协议要求)
|
|
290
|
+
const settled = await allDone;
|
|
291
|
+
let anyAborted = false;
|
|
292
|
+
for (let i = 0; i < toolCallsByIndex.length; i++) {
|
|
293
|
+
const tc = toolCallsByIndex[i];
|
|
294
|
+
const r = settled[i];
|
|
295
|
+
if (r.status === 'rejected') {
|
|
296
|
+
// 理论上 worker 已经 catch 了所有异常,这里只是防御
|
|
297
|
+
const errMsg = r.reason?.message ?? 'unknown';
|
|
169
298
|
history.push({
|
|
170
|
-
role: '
|
|
171
|
-
|
|
299
|
+
role: 'tool',
|
|
300
|
+
tool_call_id: tc.id,
|
|
301
|
+
content: `Error: ${errMsg}`,
|
|
302
|
+
timestamp: Date.now(),
|
|
172
303
|
});
|
|
173
|
-
|
|
174
|
-
return;
|
|
304
|
+
continue;
|
|
175
305
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
toolName: tc.function.name,
|
|
180
|
-
toolCallId: tc.id,
|
|
181
|
-
argsPreview,
|
|
182
|
-
};
|
|
183
|
-
const result = await executeTool(tc.function.name, tc.function.arguments, signal);
|
|
184
|
-
// 工具结果:失败保留错误信息,成功经微压缩后保留
|
|
306
|
+
const { result } = r.value;
|
|
307
|
+
if (!result.ok && result.error === 'aborted')
|
|
308
|
+
anyAborted = true;
|
|
185
309
|
const rawContent = result.ok ? result.content : `Error: ${result.error}`;
|
|
186
|
-
|
|
187
|
-
|
|
310
|
+
// 微压缩仅对成功结果走(失败结果通常很短,不需要压缩)
|
|
311
|
+
const content = result.ok
|
|
312
|
+
? microCompact(tc.function.name, rawContent, sessionState?.microCompact)
|
|
313
|
+
: rawContent;
|
|
188
314
|
history.push({
|
|
189
315
|
role: 'tool',
|
|
190
316
|
content,
|
|
191
317
|
tool_call_id: tc.id,
|
|
318
|
+
timestamp: Date.now(),
|
|
192
319
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
content,
|
|
199
|
-
};
|
|
320
|
+
}
|
|
321
|
+
if (anyAborted || signal?.aborted) {
|
|
322
|
+
// 任一工具抛 AbortError 时,外层 yield interrupted 并 return
|
|
323
|
+
yield { type: 'interrupted' };
|
|
324
|
+
return;
|
|
200
325
|
}
|
|
201
326
|
// 6. 继续 while 让模型看到 tool_result 后继续推理
|
|
202
327
|
}
|
|
@@ -231,3 +356,47 @@ function previewArgs(rawJson) {
|
|
|
231
356
|
return oneLine;
|
|
232
357
|
return oneLine.slice(0, 60) + '...';
|
|
233
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* 把工具名 + raw arguments JSON 转换成人话描述。
|
|
361
|
+
* UI 优先用这个显示在 spinner 行(如 "Reading src/foo.ts")。
|
|
362
|
+
* 已知工具走 switch;未知工具或 JSON parse 失败 fallback 到 `${toolName}(...)`。
|
|
363
|
+
*/
|
|
364
|
+
function friendlyToolDescription(toolName, rawArgsJson) {
|
|
365
|
+
let args = {};
|
|
366
|
+
try {
|
|
367
|
+
args = JSON.parse(rawArgsJson) ?? {};
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return `${toolName}(...)`;
|
|
371
|
+
}
|
|
372
|
+
const truncate = (s, n) => {
|
|
373
|
+
if (typeof s !== 'string')
|
|
374
|
+
return '';
|
|
375
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
376
|
+
};
|
|
377
|
+
switch (toolName) {
|
|
378
|
+
case 'Read': return `Reading ${truncate(args.file_path, 60)}`;
|
|
379
|
+
case 'Edit': return `Editing ${truncate(args.file_path, 60)}`;
|
|
380
|
+
case 'Write': return `Writing ${truncate(args.file_path, 60)}`;
|
|
381
|
+
case 'Bash': return `Running \`${truncate(args.command, 40)}\``;
|
|
382
|
+
case 'Grep': {
|
|
383
|
+
const p = truncate(args.pattern, 30);
|
|
384
|
+
const g = typeof args.glob === 'string' && args.glob.length > 0 ? ` in ${truncate(args.glob, 25)}` : '';
|
|
385
|
+
return `Searching \`${p}\`${g}`;
|
|
386
|
+
}
|
|
387
|
+
case 'Glob': return `Globbing \`${truncate(args.pattern, 50)}\``;
|
|
388
|
+
case 'WebFetch':
|
|
389
|
+
case 'WebBrowser': {
|
|
390
|
+
try {
|
|
391
|
+
return `Fetching ${new URL(String(args.url)).host}`;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return `Fetching ...`;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
case 'WebSearch': return `Searching \`${truncate(args.query, 50)}\``;
|
|
398
|
+
default: return `${toolName}(...)`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// 导出供测试用(friendlyToolDescription 是 module-private 纯函数)
|
|
402
|
+
export { friendlyToolDescription };
|
package/src/ui/InputBox.js
CHANGED
|
@@ -20,19 +20,38 @@ import { 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
|
+
import { useInputHistory } from './hooks/useInputHistory.js';
|
|
23
24
|
export function InputBox({ 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
30
|
// ✅ 精准刷新计数器:只在必要时触发重渲染
|
|
29
31
|
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
30
|
-
// ✅
|
|
31
|
-
const lastDeleteTime = useRef(0);
|
|
32
|
-
const DELETE_DEBOUNCE_MS = 20; // 仅 Delete 键用极短防抖
|
|
32
|
+
// ✅ 精准刷新函数
|
|
33
33
|
const forceRefresh = useCallback(() => {
|
|
34
34
|
setRefreshCounter((c) => c + 1);
|
|
35
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]);
|
|
50
|
+
// ✅ Ctrl+R 反向搜索模式(null 表示不在搜索)
|
|
51
|
+
const [searchMode, setSearchMode] = useState(null);
|
|
52
|
+
// ✅ Delete 专用去重:只防止 Windows 双事件,不影响其他键
|
|
53
|
+
const lastDeleteTime = useRef(0);
|
|
54
|
+
const DELETE_DEBOUNCE_MS = 20; // 仅 Delete 键用极短防抖
|
|
36
55
|
// ✅ 粘贴处理 Hook:支持多行粘贴
|
|
37
56
|
// v0.3 设计:Enter 始终发送, Alt+Enter(Mac: Option+Enter)换行, 粘贴期间拦截 Enter
|
|
38
57
|
const { handleInput: handlePasteInput } = usePasteHandler({
|
|
@@ -61,15 +80,72 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
|
|
|
61
80
|
return;
|
|
62
81
|
}
|
|
63
82
|
// pasteResult === 'continue',继续普通处理
|
|
83
|
+
// ---- ✅ Ctrl+R 反向搜索模式(独占按键流)----
|
|
84
|
+
if (searchMode !== null) {
|
|
85
|
+
// ESC 退出搜索
|
|
86
|
+
if (key.escape) {
|
|
87
|
+
setSearchMode(null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Enter 选中并提交
|
|
91
|
+
if (key.return) {
|
|
92
|
+
if (searchMode.hit !== null) {
|
|
93
|
+
const text = searchMode.hit;
|
|
94
|
+
setSearchMode(null);
|
|
95
|
+
buf.clear();
|
|
96
|
+
forceRefresh();
|
|
97
|
+
hist.push(text);
|
|
98
|
+
hist.resetCursor();
|
|
99
|
+
onSubmit(text);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
setSearchMode(null);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Backspace 削 query;query 削空了退出
|
|
107
|
+
if (key.backspace || key.delete) {
|
|
108
|
+
const nextQuery = searchMode.query.slice(0, -1);
|
|
109
|
+
if (nextQuery.length === 0) {
|
|
110
|
+
setSearchMode(null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const nextHit = hist.search(nextQuery)[0] ?? null;
|
|
114
|
+
setSearchMode({ query: nextQuery, hit: nextHit });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Ctrl+C 退出搜索(不退出程序)
|
|
118
|
+
if (key.ctrl && input === 'c') {
|
|
119
|
+
setSearchMode(null);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// 字面字符(排除控制键)追加到 query
|
|
123
|
+
if (input && !key.ctrl && !key.meta && !key.return && !key.escape) {
|
|
124
|
+
const nextQuery = searchMode.query + input;
|
|
125
|
+
const nextHit = hist.search(nextQuery)[0] ?? null;
|
|
126
|
+
setSearchMode({ query: nextQuery, hit: nextHit });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// 其他按键在搜索模式下静默吞掉
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
64
132
|
// ---- ESC ----
|
|
65
133
|
if (key.escape) {
|
|
66
134
|
if (disabled)
|
|
67
135
|
onAbort();
|
|
68
136
|
return;
|
|
69
137
|
}
|
|
138
|
+
// ---- ✅ Ctrl+R 进入反向搜索(仅在空 buf + 非 disabled)----
|
|
139
|
+
if (key.ctrl && input === 'r') {
|
|
140
|
+
if (!disabled && buf.state.text.length === 0) {
|
|
141
|
+
setSearchMode({ query: '', hit: null });
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
70
145
|
// ---- Ctrl+C ----
|
|
71
146
|
if (key.ctrl && input === 'c') {
|
|
72
147
|
if (buf.state.text.length > 0) {
|
|
148
|
+
flushImeBuffer();
|
|
73
149
|
buf.clear();
|
|
74
150
|
forceRefresh();
|
|
75
151
|
return;
|
|
@@ -93,9 +169,13 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
|
|
|
93
169
|
// - Ctrl+Enter:返回 'continue' → 这里也执行**强制发送**
|
|
94
170
|
// - 粘贴期间 + Enter:返回 'enter-in-paste' → **忽略**(防误发)
|
|
95
171
|
if (key.return) {
|
|
172
|
+
flushImeBuffer(); // 先提交残留 CJK 输入
|
|
96
173
|
const text = buf.state.text.trim();
|
|
97
174
|
if (text.length === 0)
|
|
98
175
|
return;
|
|
176
|
+
// 入历史(所有非空提交都记,包括 slash 命令;连续重复 hist 内部去重)
|
|
177
|
+
hist.push(text);
|
|
178
|
+
hist.resetCursor();
|
|
99
179
|
if (text === '/exit' || text === '/quit')
|
|
100
180
|
exit();
|
|
101
181
|
else if (text === '/new' || text === '/clear') {
|
|
@@ -133,11 +213,29 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
|
|
|
133
213
|
return;
|
|
134
214
|
}
|
|
135
215
|
if (key.upArrow) {
|
|
216
|
+
// 首行按 ↑ → 翻历史;中间行 → 正常移动光标
|
|
217
|
+
if (buf.layout.cursorRow === 0) {
|
|
218
|
+
const prev = hist.prev();
|
|
219
|
+
if (prev !== null) {
|
|
220
|
+
buf.setText(prev);
|
|
221
|
+
forceRefresh();
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
136
225
|
buf.moveUp();
|
|
137
226
|
forceRefresh();
|
|
138
227
|
return;
|
|
139
228
|
}
|
|
140
229
|
if (key.downArrow) {
|
|
230
|
+
// 末行按 ↓ → 翻历史;中间行 → 正常移动光标
|
|
231
|
+
const lastRow = buf.layout.lines.length - 1;
|
|
232
|
+
if (buf.layout.cursorRow >= lastRow) {
|
|
233
|
+
const nxt = hist.next();
|
|
234
|
+
// next 返 null 表示走到 "新输入" 位 → 清空缓冲
|
|
235
|
+
buf.setText(nxt ?? '');
|
|
236
|
+
forceRefresh();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
141
239
|
buf.moveDown();
|
|
142
240
|
forceRefresh();
|
|
143
241
|
return;
|
|
@@ -207,15 +305,54 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
|
|
|
207
305
|
// Alt+Enter 在多数终端发 \x1b\r:Ink 把 \x1b 剥掉后 input='\r'、key.return=false、
|
|
208
306
|
// key.meta=false,从这里落地。把 \r/\r\n 统一成 \n,textBuffer 才能真切分逻辑行
|
|
209
307
|
// (否则 layout 永远 1 行 → ↑/↓ 失效、输入框不长高,光标只能左右)。
|
|
308
|
+
//
|
|
309
|
+
// IME 兼容优化(方案C:启发式过滤 + 延迟刷新):
|
|
310
|
+
// - ASCII 可打印字符 → 立即插入+刷新(英文打字无延迟)
|
|
311
|
+
// - 非 ASCII 字符(CJK/emoji)→ 累积到 imeBuf,50ms 后批量刷新
|
|
312
|
+
// (合并连续 CJK 输入,减少 Ink 整树 rerender 次数)
|
|
210
313
|
if (input && !key.meta) {
|
|
211
314
|
const normalized = input.replace(/\r\n?/g, '\n');
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
}
|
|
214
336
|
}
|
|
215
337
|
});
|
|
338
|
+
// ✅ 搜索模式下覆盖整个 BufferView,显示 (reverse-i-search)`<query>': <hit>
|
|
339
|
+
if (searchMode !== null) {
|
|
340
|
+
return _jsx(ReverseSearchView, { query: searchMode.query, hit: searchMode.hit });
|
|
341
|
+
}
|
|
216
342
|
// ✅ 传递 refreshCounter 作为 prop(不放入 key)
|
|
217
343
|
return (_jsx(BufferView, { buf: buf, disabled: disabled, refreshCounter: refreshCounter }));
|
|
218
344
|
}
|
|
345
|
+
// ---------- 反向搜索视图 ----------
|
|
346
|
+
function ReverseSearchView({ query, hit, }) {
|
|
347
|
+
// hit 可能含换行;仅展示首行避免撑爆 InputBox
|
|
348
|
+
const hitFirstLine = hit !== null ? hit.split('\n', 1)[0] : null;
|
|
349
|
+
const hitDisplay = hitFirstLine === null
|
|
350
|
+
? '<no match>'
|
|
351
|
+
: hit !== null && hit.includes('\n')
|
|
352
|
+
? `${hitFirstLine} …`
|
|
353
|
+
: hitFirstLine;
|
|
354
|
+
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
|
+
}
|
|
219
356
|
function BufferView({ buf, disabled, refreshCounter }) {
|
|
220
357
|
// ✅ 关键:用 useMemo 依赖 refreshCounter,而非改变 key
|
|
221
358
|
// 这样不会重建组件,只是强制重新计算 layout
|
package/src/ui/MessageList.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
const MAX_TOOL_PREVIEW_LINES = 3;
|
|
4
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,
|
|
6
|
-
|
|
7
|
-
|
|
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 }) }))] }));
|
|
8
16
|
}
|
|
9
17
|
function MessageRow({ message }) {
|
|
10
18
|
switch (message.role) {
|
|
@@ -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
|
+
}
|
package/src/ui/ToolStatus.js
CHANGED
|
@@ -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\
|
|
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: [
|
|
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
|
}
|
|
@@ -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
|
+
}
|