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.
package/src/loop.js CHANGED
@@ -21,12 +21,12 @@
21
21
  * 失控保护:maxTurns(默认 50)。
22
22
  * 中断支持:AbortSignal 透传到 chat() 和 tool.call()。
23
23
  *
24
- * v0.5.3 新增"工作过程显示"事件:
24
+ * "工作过程显示"事件:
25
25
  * - stage_change:thinking / streaming / tool_executing / compacting
26
26
  * - reasoning:思维链流式增量(仅字符串型 field)
27
- * - token_tick:200ms 节流,UI 用来显示 token / 耗时心跳
28
27
  * ============================================================
29
28
  */
29
+ import crypto from 'node:crypto';
30
30
  import { autoCompactIfNeeded } from './context/compact.js';
31
31
  import { microCompact, incrementTurn, expireOldEntries } from './context/microCompactLite.js';
32
32
  import { isPromptTooLongError, reactiveCompactIfApplicable, } from './context/reactiveCompact.js';
@@ -45,15 +45,18 @@ export async function* runQuery(userInput, options) {
45
45
  const executeToolFn = options.executeTool ?? defaultExecuteTool;
46
46
  // 1. 用户消息入栈(v2:填 timestamp,让 MessageList 的 turn 边界横线
47
47
  // 能拿到首条 user 的真实时间,否则 fallback Date.now() 会丢失"提交时刻"语义)
48
- history.push({ role: 'user', content: userInput, timestamp: Date.now() });
48
+ history.push({
49
+ role: 'user',
50
+ content: userInput,
51
+ timestamp: Date.now(),
52
+ id: crypto.randomUUID(),
53
+ });
54
+ // 立即通知 UI:用户消息已入栈,触发 bump 让 <Static> 马上把它 commit 进 scrollback,
55
+ // 不必等整轮 assistant 落定(保证用户输入第一时间置顶,符合 T-A-O-R 展示顺序)。
56
+ yield { type: 'user_message_committed' };
49
57
  // 反应式压缩自救:本 runQuery 只允许触发一次,避免压缩失败导致死循环。
50
58
  // 配合 reactiveCompact.ts 的 attempted(session 级)双层防爆。
51
59
  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;
57
60
  let turn = 0;
58
61
  while (turn < maxTurns) {
59
62
  turn++;
@@ -65,6 +68,7 @@ export async function* runQuery(userInput, options) {
65
68
  role: 'user',
66
69
  content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
67
70
  timestamp: Date.now(),
71
+ id: crypto.randomUUID(),
68
72
  });
69
73
  yield { type: 'error', error: '已被用户中断' };
70
74
  return;
@@ -103,8 +107,6 @@ export async function* runQuery(userInput, options) {
103
107
  const reasoningDetails = [];
104
108
  /** 首个 text_delta 时切到 streaming,再来不重复 yield */
105
109
  let stageStreamingYielded = false;
106
- /** 200ms token_tick 节流游标 */
107
- let lastTick = Date.now();
108
110
  // 进入 LLM 流前:等首 token / reasoning,本质是 thinking
109
111
  yield { type: 'stage_change', stage: 'thinking' };
110
112
  try {
@@ -138,25 +140,8 @@ export async function* runQuery(userInput, options) {
138
140
  // reasoning_details 是结构化数组,没有 delta 字符串,跳过 yield
139
141
  reasoningDetails.push(...ev.items);
140
142
  }
141
- if (ev.delta) {
142
- yield { type: 'reasoning_delta', field: ev.field, delta: ev.delta };
143
- }
144
143
  }
145
144
  // 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
- }
160
145
  }
161
146
  }
162
147
  catch (e) {
@@ -166,6 +151,7 @@ export async function* runQuery(userInput, options) {
166
151
  role: 'user',
167
152
  content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
168
153
  timestamp: Date.now(),
154
+ id: crypto.randomUUID(),
169
155
  });
170
156
  yield { type: 'interrupted' };
171
157
  return;
@@ -204,6 +190,7 @@ export async function* runQuery(userInput, options) {
204
190
  ...(reasoningString ? { reasoning: reasoningString } : {}),
205
191
  ...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}),
206
192
  timestamp: Date.now(),
193
+ id: crypto.randomUUID(),
207
194
  };
208
195
  history.push(assistantMsg);
209
196
  yield { type: 'assistant_message', message: assistantMsg };
@@ -228,8 +215,6 @@ export async function* runQuery(userInput, options) {
228
215
  signalNew = null;
229
216
  };
230
217
  const workers = toolCallsByIndex.map(async (tc) => {
231
- // 每个 worker 启动时累加 step 序号;token_tick 携带它给 UI 显示进度感
232
- stepN++;
233
218
  enqueue({
234
219
  type: 'tool_start',
235
220
  toolCallId: tc.id,
@@ -300,6 +285,7 @@ export async function* runQuery(userInput, options) {
300
285
  tool_call_id: tc.id,
301
286
  content: `Error: ${errMsg}`,
302
287
  timestamp: Date.now(),
288
+ id: crypto.randomUUID(),
303
289
  });
304
290
  continue;
305
291
  }
@@ -316,6 +302,7 @@ export async function* runQuery(userInput, options) {
316
302
  content,
317
303
  tool_call_id: tc.id,
318
304
  timestamp: Date.now(),
305
+ id: crypto.randomUUID(),
319
306
  });
320
307
  }
321
308
  if (anyAborted || signal?.aborted) {
@@ -11,6 +11,15 @@ function stripQuotes(s) {
11
11
  }
12
12
  return trimmed;
13
13
  }
14
+ /**
15
+ * 跨平台取路径最后一段。Windows 上 join() 产出反斜杠路径,单纯 split('/')
16
+ * 不切分会让整条绝对路径变成"目录名"。同时吃 \\ 与 /,与
17
+ * hookEngine.pluginNameFromRoot 的归一逻辑保持一致。
18
+ */
19
+ export function baseName(p) {
20
+ const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
21
+ return parts[parts.length - 1] || p;
22
+ }
14
23
  function parseMarkdownFrontmatter(content) {
15
24
  const match = content.match(/^---\n([\s\S]*?)\n---/);
16
25
  if (!match)
@@ -28,7 +37,7 @@ function parseMarkdownFrontmatter(content) {
28
37
  return frontmatter;
29
38
  }
30
39
  async function loadPlugin(pluginDirPath) {
31
- const dirName = pluginDirPath.split('/').pop() ?? pluginDirPath;
40
+ const dirName = baseName(pluginDirPath);
32
41
  const manifestPath = join(pluginDirPath, '.claude-plugin', 'plugin.json');
33
42
  let manifestName = dirName;
34
43
  let manifestVersion;
@@ -101,6 +110,9 @@ export async function discoverPlugins() {
101
110
  discoveryDone = true;
102
111
  // 双源扫描:cwd 优先 + packageRoot fallback;按数组顺序处理,已有 manifestName 跳过
103
112
  const searchPaths = getResourceSearchPaths('plugins', import.meta.url);
113
+ // 命令名 → 首个声明它的插件名。resolveCommand 按插入顺序返回第一个命中,
114
+ // 所以两个不同插件声明同名命令时,先注册者生效、后者被影 —— 不再静默,warn 一次。
115
+ const commandOwners = new Map();
104
116
  for (const root of searchPaths) {
105
117
  try {
106
118
  const entries = await readdir(root, { withFileTypes: true });
@@ -115,6 +127,16 @@ export async function discoverPlugins() {
115
127
  if (pluginCache.has(plugin.name))
116
128
  continue; // cwd 已注册的 manifestName,packageRoot 同名跳过
117
129
  pluginCache.set(plugin.name, plugin);
130
+ for (const cmd of plugin.commands) {
131
+ const owner = commandOwners.get(cmd.name);
132
+ if (owner && owner !== plugin.name) {
133
+ console.warn(`[minimal-agent] 命令名冲突:/${cmd.name} 同时由插件 "${owner}" 与 "${plugin.name}" 声明;` +
134
+ `先注册的 "${owner}" 生效,"${plugin.name}" 的同名命令被忽略。`);
135
+ }
136
+ else if (!owner) {
137
+ commandOwners.set(cmd.name, plugin.name);
138
+ }
139
+ }
118
140
  }
119
141
  }
120
142
  catch {
@@ -147,9 +169,11 @@ export function buildCommandInput(resolved) {
147
169
  const { cmd, arguments: args } = resolved;
148
170
  let input = cmd.promptBody;
149
171
  input = input.replaceAll('${CLAUDE_PLUGIN_ROOT}', cmd.pluginRoot);
172
+ // 模板是否自己引用了参数占位符。引用了就别再补尾注,否则参数出现两遍。
173
+ const hadArgsPlaceholder = input.includes('$ARGUMENTS') || input.includes('${ARGUMENTS}');
150
174
  input = input.replaceAll('$ARGUMENTS', args);
151
175
  input = input.replaceAll('${ARGUMENTS}', args);
152
- if (args.trim()) {
176
+ if (!hadArgsPlaceholder && args.trim()) {
153
177
  input += `\n\n用户参数: ${args.trim()}`;
154
178
  }
155
179
  return input;
@@ -34,17 +34,6 @@ export async function getSystemPrompt(cwd, tools) {
34
34
 
35
35
  # 思维与立场(核心:像专家一样独立思考)
36
36
  - **你是工程协作者,不是奉承者**。不无条件夸赞、不为附和而附和、不同意时直说。不用"很好的问题"、"你说得对"、"完全同意"这类前缀——直接答内容。
37
- - **先判断用户意图再行动**——这是最重要的第一步,区分以下三种情况:
38
- - ✅ **明确执行(直接动手,不要犹豫)**:用户表述包含明确的行动指令。
39
- 动词特征:「修改/改/调整/修复/优化/更新/替换/删/移除/写/创建/实现/做/生成/新建/开发/执行/跑/运行/提交/部署/安装」
40
- 确认性指令也算:「对就这么改」「是的按这个方案实现」「好的麻烦改一下」
41
- - ❌ **纯讨论(绝对不能动文件,只回答)**:用户只是提问、咨询、探讨、评估。
42
- 特征:问号结尾、「是不是/怎么样/为什么/好不好/能否/你觉得」、纯观点表达「我觉得...」「这里好像有...」
43
- 即使你发现明显问题,也只能给文字建议,**禁止调用 Edit/Write/MultiEdit/Bash 写操作等任何修改类工具**
44
- - ❓ **模糊场景(仅一次确认,不要啰嗦)**:介于两者之间无法判断时。
45
- 例:「这个接口响应太慢了」「这个正则好像有问题」
46
- 只问一句:「你是需要我实际修改/修复,还是只是讨论这个问题?」—— 用户说改就立刻改,说讨论就停
47
- - **兜底原则**:宁可多问一次也不要擅自修改;但只要用户明确说要改,必须立刻执行,不要二次确认。
48
37
  - **基于证据,不基于揣测用户想听什么**。没读过的代码先 Read,没验证过的行为先验证,不凭印象答。回答前先问自己:这个判断有具体证据吗,还是我在猜?
49
38
  - **不知道就说不知道**。明确边界:"这个版本号我不确定"、"这个 API 我没看过文档"、"这部分行为我没验证过"。胡编一个看起来对的答案,代价远高于承认不知道。
50
39
  - **明示 confidence 分层**。结论按确定度分层表达——"已 verify / 高度确信"、"基于代码合理推测"、"未验证猜测"——让用户基于你的不确定程度决策,不要把推测说成事实。
@@ -52,6 +41,7 @@ export async function getSystemPrompt(cwd, tools) {
52
41
  - **给根因不给表面答案**。bug 报告先定位 root cause 再给 fix,不要先给个让症状消失的 workaround 蒙混过去。修复要能解释"为什么这样改就对了"。
53
42
  - **任何方案都有代价**。主动说 trade-off(性能 vs 可读、激进 vs 保守、本期 vs 长期),让用户基于全貌决策,不要只夸方案的优点。
54
43
  - **被指出错误时直接承认 + 修正**。不解释、不防御、不找借口。承认 → 给出修正 → 避免下次。错误是 update prior 的信号,不是要消化的尴尬。
44
+ - **读过就别反复读,验证过就别反复验**。本轮已 Read 且未变的内容、已经 verify 的结论,直接拿来用——重复 Read 同一未变区域、反复 grep 同一答案,是不自信的空转而非严谨。真严谨是"一次读对、读透",不是"读很多次";读完就基于已读内容推进。
55
45
 
56
46
  # 工作环境
57
47
  - 当前工作目录: ${cwd}
@@ -73,6 +63,7 @@ ${toolList}
73
63
  - 修改既有文件之前,必须用 Read 工具读取相关代码的最新内容。
74
64
  "我记得"、"我之前看过"、"应该差不多"都不算读过——必须在本轮任务中实际调用 Read。
75
65
  尤其当你要参考某个文件的写法来实现类似功能时,必须先重读该文件的关键部分(函数实现、判断逻辑、数据流),理解清楚后再动手。
66
+ 反过来:**本轮已 Read 过、且你没用 Edit/Write 改动过的区域,不要重复 Read**——直接基于已读内容操作。如果你重复读取同一未变区域,结果头部会出现"已读过该区域"的提示,看到就停止重读、立刻用已有内容推进(重复读既费轮次又是死循环前兆)。
76
67
  - Edit / MultiEdit 拦截 fallback:如果你忘了先 Read 就直接调 Edit/MultiEdit,错误响应里会**自动附带当前文件前 200 行 / 32KB 以内的 snippet**(带行号)。这种情况下**直接基于 snippet 重新构造 old_string 再发一次 Edit 即可,不要再额外调 Read**。只有当 snippet 显示"未注入内容"(文件过大或二进制)时才需要显式 Read。Write 不附带 snippet——整文件覆盖语义下必须先显式 Read,避免基于片段重写导致截断范围外内容丢失。
77
68
  - Edit 工具的 old_string 必须在文件中唯一;不唯一时请扩大上下文或显式 replace_all=true。
78
69
  - 同一文件需要修改多处(3 处及以上)时优先用 MultiEdit:所有 edit 按顺序在内存中应用,**全部成功才落盘**;任一失败磁盘不动,避免中间状态污染。单点修改继续用 Edit。
@@ -138,16 +129,25 @@ ${skillHint}
138
129
  - 如果用户的请求不清晰,先用一句话澄清再动手。
139
130
  - 任务完成后用一两句话总结改了什么,不要长篇大论。
140
131
 
132
+ # 修改 vs 讨论(动手边界,重要)
133
+ 先判断用户要的是"动手改"还是"聊一聊",再决定要不要写盘——默认克制、被要求才行动:
134
+ - **默认是讨论/分析/给方案**:除非判定用户要你改,否则只回答、只给建议,**不调用 Edit/Write/MultiEdit、不跑会改状态或破坏性的 Bash**。未经允许的修改和"不读就改"一样是越界。
135
+ - **只读工具不受此限**:Read/Grep/Glob/WebSearch/WebFetch 任何时候都能直接用——读代码、查资料是"理解"不是"动手",讨论也要先把事实读准。
136
+ - **命中修改意图就果断改,别反问试探**:祈使式动词(改/加/删/修/修复/重构/实现/优化/重命名/迁移/接上/补全…)、明确的"直接改/帮我写/动手/落地"、或一个可执行的 bug 修复请求——都是放行信号,直接干。别再用"要不要我帮你改"来回打转,那是讨好型的犹豫。
137
+ - **纯讨论意图就别擅自改**:以"为什么/是不是/能不能/看看/评估下/解释下/你怎么看/可行吗/有没有问题"为主、没有修改祈使的,给分析和建议即可,不要顺手把代码改了。
138
+ - **只有"讨论还是修改"真拿不准时**,才用一句话澄清("你是想我直接改,还是先讨论方案?")再停;能从措辞判断的就别问。
139
+
141
140
  # 中断支持
142
141
  - 用户可以随时按 ESC 或 Ctrl+C 中断你的工作。
143
142
  - 中断后等待用户输入新任务,不要自行继续执行被中断的操作。
144
143
  - 如果用户输入了新任务,直接响应新任务,不必提及之前被中断的操作。
145
144
 
146
- # 任务识别
147
- - 用户开始新任务时("帮我做 X"、"这次做 YYY"),不要继续之前未完成的旧任务
148
- - 用户说"继续""接着做""再加一个"时,保持当前任务上下文
149
- - 用户说"重新开始"、"不算了,做 X"时,立即放下旧任务
150
- - 意图模糊时,先用一句话澄清:"你是继续刚才的,还是开始新的?"`;
145
+ # 任务边界与上下文关联(每轮开工前自检)
146
+ 用户不会一问一会话,历史里常混着已结束的旧任务。每轮开工前先答三个问题,再决定怎么用上下文:
147
+ 1. **这是新任务还是上一任务的延续?**——"继续/接着做/再加一个/还有"=延续,保持当前任务上下文;"帮我做 X/这次做 Y/另外"=多半是新任务;"重新开始/不算了,做 X"=立即放下旧任务。
148
+ 2. **历史里哪些和当前问题相关?**——只把与当前问题直接相关的消息、文件、工具结果当活动上下文;无关的旧任务细节当背景,**不要拿旧任务的结论/文件/方案硬套当前问题**(典型翻车:上个任务在改 A,这次问的是 B,却惯性去翻 A)。
149
+ 3. **判不准边界就先确认**:一句话澄清"你是继续刚才的 X,还是开始新的?",别默默假设。
150
+ - 当前用户消息 + 紧邻的最近几条消息,权重永远高于更早历史;越早、越不相关的内容参考价值越低。`;
151
151
  }
152
152
  /**
153
153
  * 生成完整系统提示词:getSystemPrompt + 项目级 minimal-agent.md 指令。
@@ -62,6 +62,8 @@ Usage:
62
62
  - This tool cannot read binary files. Files whose extensions are on the binary blocklist (images: .png/.jpg/.gif/.webp/..., documents: .pdf/.docx/.xlsx/..., executables: .exe/.dll/.so/..., archives: .zip/.tar/.gz/..., and others) will be rejected with a clear error. If the file is actually text despite the extension (e.g., a misnamed log), rename it or use Bash \`cat\` to read it directly — do not retry Read with the same path.`;
63
63
  // ---------------- 4. call() —— 真正干活 ----------------
64
64
  const STREAM_THRESHOLD = 1024 * 1024;
65
+ /** "同区重读空转"提示:同一区间 + 文件未变 + 第二次起,前置到结果头部(仅提示,不拦截)。 */
66
+ const REREAD_HINT = '⚠️ 你本轮已经读过该文件的这一区间,且文件未发生变化——请直接基于上次读到的内容操作,不要重复读取空转。如果你其实在找别的内容,改用不同的 offset/limit,或用 Grep 精确定位。\n\n';
65
67
  async function call(input) {
66
68
  const offset = input.offset ?? 1;
67
69
  const limit = input.limit ?? MAX_LINES_TO_READ;
@@ -145,7 +147,9 @@ async function call(input) {
145
147
  if (st.size > 100 * 1024 && offset === 1) {
146
148
  content += `\n\n⚠️ 注意:这是一个大文件(${(st.size / 1024).toFixed(1)} KB)。建议用 offset/limit 分段读取,例如先读关键部分(imports、exports、函数签名)。`;
147
149
  }
148
- recordRead(filePath, { truncated });
150
+ const readCount = recordRead(filePath, { truncated, offset, limit });
151
+ if (readCount >= 2)
152
+ content = REREAD_HINT + content;
149
153
  return { ok: true, content };
150
154
  }
151
155
  async function readSmallFile(filePath, offset, limit) {
@@ -12,21 +12,44 @@
12
12
  * 3. 当前 mtime > 记录的 mtime → 报错"文件被外部修改"
13
13
  *
14
14
  * /new 调用 clearContext 时连带调 clearFileState() 防残留。
15
+ *
16
+ * v2 增量(防"读后空转"):recordRead 额外记录最近一次读取的 (offset, limit)
17
+ * 与"同区连续重读次数",并返回该次数。Read 工具据此在"同一区间 + 文件未变 +
18
+ * 第二次起"时给模型一行"别重复读、直接用已读内容"的提示(仅提示,不拦截)。
19
+ * 文件一旦被 Edit/Write 或外部改动(mtime 漂移),重读即合法、计数归 1。
15
20
  * ============================================================
16
21
  */
17
22
  import { existsSync, statSync } from 'node:fs';
18
23
  const fileState = new Map();
24
+ /**
25
+ * 记录一次 Read,返回本次是"同区重读"的第几次(首次=1)。
26
+ *
27
+ * "同区重读" = 文件 mtime 未漂移(内容没变) + 这次 (offset,limit) 与上次完全相同。
28
+ * 调用方(Read 工具)据返回值 >= 2 判定模型在空转、给提示。
29
+ * 文件被 Edit/Write 或外部改动(mtime 漂移)即视为合法重读,计数归 1、不提示。
30
+ */
19
31
  export function recordRead(absPath, opts) {
20
32
  try {
21
33
  const st = statSync(absPath);
34
+ const prev = fileState.get(absPath);
35
+ const sameRegionUnchanged = prev !== undefined &&
36
+ st.mtimeMs <= prev.timestamp &&
37
+ prev.offset === opts?.offset &&
38
+ prev.limit === opts?.limit;
39
+ const reads = sameRegionUnchanged ? (prev.reads ?? 1) + 1 : 1;
22
40
  fileState.set(absPath, {
23
41
  timestamp: st.mtimeMs,
24
42
  size: st.size,
25
43
  truncated: opts?.truncated ?? false,
44
+ offset: opts?.offset,
45
+ limit: opts?.limit,
46
+ reads,
26
47
  });
48
+ return reads;
27
49
  }
28
50
  catch {
29
51
  // 文件不存在或无权访问 —— 不记录,让 Read 自己处理错误
52
+ return 1;
30
53
  }
31
54
  }
32
55
  /** 查询上次 Read 是否被截断。供 Edit/MultiEdit 给出更精准 error hint、Write 做 hard guard 用。 */
package/src/ui/App.js CHANGED
@@ -7,31 +7,175 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * 不直接处理键盘输入,那是 InputBox 的事。
8
8
  * ============================================================
9
9
  */
10
- import React from 'react';
11
- import { Box, Text, Static } from 'ink';
10
+ import React, { useDeferredValue } from 'react';
11
+ import { Box, Text } from 'ink';
12
+ import { getWorkingDir } from '../bootstrap/workingDir.js';
13
+ import { listArchives, restoreArchive } from '../context/archive.js';
12
14
  import { saveContext } from '../context/persistContext.js';
15
+ import { sessionFileFor } from '../context/sessionPath.js';
16
+ import { listSessions } from '../context/sessionRegistry.js';
13
17
  import { InputBox } from './InputBox.js';
18
+ import { LiveArea } from './LiveArea.js';
14
19
  import { MessageList } from './MessageList.js';
15
20
  import { StatusLine } from './StatusLine.js';
16
- import { ToolStatus } from './ToolStatus.js';
17
21
  import { useChat } from './hooks/useChat.js';
18
- /** ANSI escape: 清屏 + 光标归位 */
22
+ import { useTerminalSize } from './hooks/useTerminalSize.js';
23
+ /**
24
+ * ANSI escape: 清屏 + 光标归位。
25
+ *
26
+ * 注意:项目里仅这两处保留 process.stdout.write 旁路 Ink:
27
+ * 1) onCompactDone(压缩完)
28
+ * 2) /new 清空历史后
29
+ * CLEAR_SCREEN 是**一次性**写入 + 整屏清空的语义,不存在"反复写新内容到 live 区"
30
+ * 的问题;ESC[2J 清屏后 ink 下次 onRender 会重发完整 lastOutput,lineCount 残留
31
+ * 也不会留下脏行(写在空白处)。其它"用户看到的非消息内容"必须走 chat.ephemeralLine
32
+ * 走 Ink 渲染管线,否则会破坏 log-update 计数。
33
+ */
19
34
  const CLEAR_SCREEN = '\x1b[2J\x1b[H';
20
35
  export function App({ provider, initialHistory }) {
21
- // 持久化挂钩:每次轮次结束 / 清空 / 压缩后由 useChat 触发;fire-and-forget。
22
- const onPersist = React.useCallback((messages) => {
23
- void saveContext(messages);
24
- }, []);
36
+ // v2 multi-session:持久化路径完全由 useChat 内部管控(按 activeSessionId 选文件)。
37
+ // App 不再传 onPersist —— 之前 App 端调 saveContext(messages) 会污染 default session。
25
38
  const chat = useChat({
26
39
  provider,
27
40
  initialHistory,
28
- onPersist,
29
41
  onCompactDone: () => process.stdout.write(CLEAR_SCREEN),
30
42
  });
43
+ // 终端尺寸感知:rows → MessageList 决定窄终端是否折叠 reasoning + LiveArea 收敛预览高;
44
+ // cols → LiveArea 逐行截断防换行
45
+ const { rows: terminalRows, cols: terminalCols } = useTerminalSize();
46
+ /**
47
+ * 用 useDeferredValue 把 history 推后到低优先级渲染队列。
48
+ *
49
+ * 流式期间 transient state 高频 setState 是高优先级,
50
+ * 而 MessageList 重算 turn meta + Static reconcile 比较重,可以"等空闲再做"。
51
+ * React 在繁忙时会让 deferredHistory 保持旧值跳过 MessageList rerender,
52
+ * 直到 streamingText 稳定下来再 commit 新 history。
53
+ *
54
+ * 注意:assistant_message 落定时 history 增长 + streamingText 清空是同一次
55
+ * runQuery yield —— useDeferredValue 不会无限延迟,最终一定会 catch up,
56
+ * Static commit 新 message 进 scrollback 不会丢。
57
+ */
58
+ const deferredHistory = useDeferredValue(chat.history);
31
59
  // /new:内存 reset + 磁盘 clearContext 全部由 chat.clearHistory() 内部完成,await 之后再清屏。
60
+ // ★ deps 用精细字段 chat.clearHistory,不是整个 chat。
61
+ // 理由:useChat 返回的 chat 对象每次 useChat 重渲都是新对象字面量引用。
62
+ // 如果 deps=[chat],流式期 reasoningPreview/streamingText 每 100ms flush 触发
63
+ // useChat rerender → chat 新引用 → handleClear 新引用 → InputBox 收到新 onClear
64
+ // prop → 强制重渲 → 整个 live 区被 Ink 重写一次(看起来就是 spinner 每帧打新行)。
32
65
  const handleClear = React.useCallback(async () => {
33
66
  await chat.clearHistory();
34
67
  process.stdout.write(CLEAR_SCREEN);
35
- }, [chat]);
36
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [null], children: () => (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "minimal-agent" }), _jsx(Text, { color: "gray", children: ` · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断` })] })) }), _jsx(MessageList, { history: chat.history, streamingText: chat.streamingText, streamingReasoning: chat.streamingReasoning }), _jsx(ToolStatus, { status: chat.toolStatus, compacting: chat.compacting }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: chat.submit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(Static, { items: [chat.history.length], children: () => (_jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })) })] }));
68
+ }, [chat.clearHistory]);
69
+ // history ref:在 handleSubmit 闭包里读最新 chat.history,避免每次流式 flush
70
+ // 重建 callback;同时避免把 chat.history 放进 deps 导致 InputBox 频繁 rerender。
71
+ const historyRef = React.useRef(chat.history);
72
+ React.useEffect(() => {
73
+ historyRef.current = chat.history;
74
+ }, [chat.history]);
75
+ /**
76
+ * v2 slash 命令拦截层。
77
+ *
78
+ * InputBox 已经拦截了 /exit /quit /new /compact 不走 onSubmit;
79
+ * 其他 / 开头命令落到这里。命中命令走 chat.ephemeralLine() 把提示作为
80
+ * _ephemeral 的 system 消息推进 history → MessageList <Static> 把它 commit
81
+ * 进 scrollback;不调 chat.submit 避免被 LLM 当成用户输入。
82
+ *
83
+ * 旧方案是 process.stdout.write 旁路 Ink,但那样会破坏 ink log-update 的
84
+ * previousLineCount 计数,下一帧 eraseLines 用过期 N 去清会留下脏行。
85
+ *
86
+ * 支持:
87
+ * /session <name> — 切到指定 session(同 cwd)
88
+ * /sessions — 列当前 cwd 下所有 session
89
+ * /list-archive — 列归档
90
+ * /restore <id> — 恢复归档为当前 session 历史(覆盖+重读)
91
+ * /show <id-suffix> — 打印 tool_call_id 末尾匹配的完整 tool result
92
+ */
93
+ const handleSubmit = React.useCallback(async (text) => {
94
+ // /session <name>
95
+ if (text.startsWith('/session ')) {
96
+ const id = text.slice('/session '.length).trim();
97
+ if (id.length === 0) {
98
+ chat.ephemeralLine('[/session] 用法:/session <name>');
99
+ return;
100
+ }
101
+ await chat.switchSession(id);
102
+ chat.ephemeralLine(`[/session] 已切换到 session "${id}"`);
103
+ return;
104
+ }
105
+ // /sessions
106
+ if (text.trim() === '/sessions') {
107
+ const ids = await listSessions(getWorkingDir());
108
+ const lines = ids
109
+ .map((id) => (id === chat.activeSessionId ? `* ${id}` : ` ${id}`))
110
+ .join('\n');
111
+ chat.ephemeralLine(`[/sessions] 当前 cwd 下的 session(* = 激活):\n${lines}`);
112
+ return;
113
+ }
114
+ // /list-archive
115
+ if (text.trim() === '/list-archive') {
116
+ const entries = await listArchives();
117
+ if (entries.length === 0) {
118
+ chat.ephemeralLine('[/list-archive] 当前 cwd 下无归档。');
119
+ return;
120
+ }
121
+ const lines = entries
122
+ .slice(0, 30)
123
+ .map((e) => ` ${e.id} · ${e.msgCount} msgs · ${e.firstUserPreview || '(empty)'}`)
124
+ .join('\n');
125
+ const tail = entries.length > 30 ? `\n ...(共 ${entries.length} 条,仅显示前 30)` : '';
126
+ chat.ephemeralLine(`[/list-archive] 归档列表(最新优先):\n${lines}${tail}`);
127
+ return;
128
+ }
129
+ // /restore <id>
130
+ if (text.startsWith('/restore ')) {
131
+ const id = text.slice('/restore '.length).trim();
132
+ if (id.length === 0) {
133
+ chat.ephemeralLine('[/restore] 用法:/restore <archive-id>');
134
+ return;
135
+ }
136
+ const messages = await restoreArchive(id);
137
+ if (!messages) {
138
+ chat.ephemeralLine(`[/restore] 未找到 archive id "${id}"`);
139
+ return;
140
+ }
141
+ // 覆盖当前 active session 文件;强制 reload 让 history 替换
142
+ const targetFile = sessionFileFor(getWorkingDir(), chat.activeSessionId);
143
+ await saveContext(messages, targetFile);
144
+ await chat.switchSession(chat.activeSessionId, { force: true });
145
+ chat.ephemeralLine(`[/restore] 已恢复 archive ${id} 到 session "${chat.activeSessionId}"(${messages.length} 条消息)`);
146
+ return;
147
+ }
148
+ // /show <id-suffix>
149
+ if (text.startsWith('/show ')) {
150
+ const suffix = text.slice('/show '.length).trim();
151
+ if (suffix.length === 0) {
152
+ chat.ephemeralLine('[/show] 用法:/show <tool_call_id 末尾 8 字符>');
153
+ return;
154
+ }
155
+ const hit = historyRef.current.find((m) => m.role === 'tool' && m.tool_call_id.endsWith(suffix));
156
+ if (!hit || hit.role !== 'tool') {
157
+ chat.ephemeralLine(`[/show] 未找到 tool_call_id 末尾匹配 "${suffix}" 的 tool result`);
158
+ return;
159
+ }
160
+ chat.ephemeralLine(`[tool result · #${hit.tool_call_id.slice(-8)} · 完整 ${hit.content.split('\n').length} 行]\n${hit.content}`);
161
+ return;
162
+ }
163
+ // 非命令 → 走正常 LLM 流程
164
+ void chat.submit(text);
165
+ },
166
+ // ★ 精细 deps:用 chat 的具体方法/字段,**不是**整个 chat 对象。
167
+ // useChat 每次重渲都返回新的 chat 对象字面量;用 [chat] 会让 handleSubmit
168
+ // 随每次流式 flush(100ms)重建一次 → InputBox 收到新 onSubmit → 强制重渲
169
+ // → Ink 重写 live 区 → spinner 看起来每帧打新行。
170
+ [
171
+ chat.ephemeralLine,
172
+ chat.switchSession,
173
+ chat.activeSessionId,
174
+ chat.submit,
175
+ ]);
176
+ // banner 改作为 Static items[0] 走 MessageList。原因:Ink 5 的 Static 约定
177
+ // 它必须是兄弟节点中第一个,否则 above-sibling 会跟 Static commit 一起被重打
178
+ // 到 stdout(实测看到 banner 反复打印 N 次)。
179
+ const bannerText = `minimal-agent · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断`;
180
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MessageList, { history: deferredHistory, compactGeneration: chat.compactGeneration, bannerText: bannerText, terminalRows: terminalRows }), _jsxs(Box, { flexDirection: "column", width: Math.max(1, terminalCols - 1), children: [_jsx(LiveArea, { progressStage: chat.progressStage, reasoningPreview: chat.reasoningPreview, streamingText: chat.streamingText, runningTools: chat.runningTools, pluginProgress: chat.pluginProgress, terminalCols: terminalCols, terminalRows: terminalRows }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: handleSubmit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })] })] }));
37
181
  }