mocode-ai 1.1.8 → 1.1.10

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.
@@ -1,7 +1,7 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, reinjectActivePlanIntoSystem, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
5
5
  import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
6
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
7
7
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
@@ -72,6 +72,13 @@ function buildSlashCommands() {
72
72
  { name: 'status', value: '/subagent status', desc: d('commands.subagentStatus') },
73
73
  ],
74
74
  },
75
+ {
76
+ name: '/fe', desc: d('commands.fe'), children: [
77
+ { name: 'on', value: '/fe on', desc: d('commands.feOn') },
78
+ { name: 'off', value: '/fe off', desc: d('commands.feOff') },
79
+ { name: 'status', value: '/fe status', desc: d('commands.feStatus') },
80
+ ],
81
+ },
75
82
  { name: '/theme', desc: d('commands.theme') },
76
83
  {
77
84
  name: '/model', desc: d('commands.model'), children: [
@@ -1469,6 +1476,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1469
1476
  // focus 透传到 compact_history action 的 LLM 摘要 prompt。
1470
1477
  // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1471
1478
  const log = await manualCompact(history, focus, { force });
1479
+ // ② compact 后把活跃 plan 重注入系统提示(history[0]),避免 agent 因上下文压缩丢失执行计划。
1480
+ if (log.compactHistoryCalled)
1481
+ reinjectActivePlanIntoSystem(history);
1472
1482
  const d = log.compactDetail;
1473
1483
  appendCurrentSessionRuntimeEvent('compact', {
1474
1484
  source: 'manual',
@@ -2012,6 +2022,44 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
2012
2022
  layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'subagent.changedOn' : 'subagent.changedOff')}${ui.reset}\n`);
2013
2023
  continue;
2014
2024
  }
2025
+ if (line === '/fe' ||
2026
+ line.startsWith('/fe ') ||
2027
+ line === '/frontend' ||
2028
+ line.startsWith('/frontend ')) {
2029
+ // /fe — 前端工具簇总开关(browser / dev_server / screenshot / view_image)。
2030
+ // 无参切换 on/off;有参 on|off 显式;status 只读。设计同 /subagent:单一来源
2031
+ // isFrontendToolsEnabled();关闭时 4 个工具不进模型 schema(refreshChatTools 过滤)、
2032
+ // 运行时 getRuntimeDisabledTools 兜底拦截、plan 模式 getPlanDisabledTools 也剔除。默认 false。
2033
+ const raw = line.startsWith('/fe ')
2034
+ ? line.slice('/fe '.length)
2035
+ : line.startsWith('/frontend ')
2036
+ ? line.slice('/frontend '.length)
2037
+ : line === '/frontend'
2038
+ ? ''
2039
+ : line.slice('/fe'.length);
2040
+ const arg = raw.trim().toLowerCase();
2041
+ if (arg === '' || arg === 'status') {
2042
+ const enabled = isFrontendToolsEnabled();
2043
+ const state = t(enabled ? 'fe.stateOn' : 'fe.stateOff');
2044
+ layout.contentWrite(`${ui.accent}${t('fe.status', { state })}${ui.reset}\n` +
2045
+ `${ui.dim}MOCODE_FRONTEND_TOOLS_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n`);
2046
+ continue;
2047
+ }
2048
+ if (arg !== 'on' && arg !== 'off') {
2049
+ layout.contentWrite(`${ui.yellow}${t('fe.usage')}${ui.reset}\n`);
2050
+ continue;
2051
+ }
2052
+ const enabled = arg === 'on';
2053
+ if (enabled !== isFrontendToolsEnabled()) {
2054
+ updateFrontendToolsConfig(enabled);
2055
+ updateConfigKey('MOCODE_FRONTEND_TOOLS_ENABLED', enabled ? 'true' : 'false');
2056
+ refreshChatTools();
2057
+ history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
2058
+ layout.rewriteBanner(bannerLines(banner()));
2059
+ }
2060
+ layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'fe.changedOn' : 'fe.changedOff')}${ui.reset}\n`);
2061
+ continue;
2062
+ }
2015
2063
  if (line === '/memory_switch' ||
2016
2064
  line.startsWith('/memory_switch ') ||
2017
2065
  line === '/memory_status' ||
@@ -0,0 +1,107 @@
1
+ // session/notes.ts - Session Notepad(notes.md)的 plan 状态单一事实源。
2
+ // 负责:canonical 文件路径、plan 结构化模型、markdown 渲染、以及"只替换活跃 ## Plan: 段、
3
+ // 保留其它笔记段"的写入。被 plan_update 工具、agent core(事件驱动重同步/nag)、
4
+ // config(notepad 索引 / compact 重注入)共用,避免路径与段解析逻辑散落多处。
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { getSandboxRoot } from '../sandbox/root.js';
8
+ import { getCurrentSessionId } from './state.js';
9
+ /** 单个活跃 plan 允许的最大步骤数(防无限膨胀,对齐 Claude Code TodoWrite 的 20 上限)。 */
10
+ export const PLAN_MAX_STEPS = 20;
11
+ /** 当前会话 notes.md 的绝对路径;无会话时返 null。 */
12
+ export function getNotesFilePath(sessionId = getCurrentSessionId()) {
13
+ if (!sessionId)
14
+ return null;
15
+ const root = getSandboxRoot() ?? process.cwd();
16
+ return path.join(root, '.mocode', 'sessions', sessionId, 'notes.md');
17
+ }
18
+ /** notes.md 的 mtime(ms),不存在或不可读返 null。供 core 判断"本步是否改动了 notes.md"。 */
19
+ export function getNotesMtime(sessionId = getCurrentSessionId()) {
20
+ const p = getNotesFilePath(sessionId);
21
+ if (!p)
22
+ return null;
23
+ try {
24
+ return fs.statSync(p).mtimeMs;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /**
31
+ * 把 plan 渲染为 canonical markdown 段(不含首尾多余空行)。
32
+ * 复选框格式 `- [ ] N.` / `- [x] N.` 与 repl 状态栏 chip 的计数正则严格对齐;
33
+ * in_progress 步骤追加 ` ◀ 当前` 后缀(仍计为未完成),让 compact 重注入后模型能直接续上。
34
+ * 全部完成时标题写为 `## Done:`(自动结算),extractActivePlanSection 随之返回 null。
35
+ */
36
+ export function renderPlanSection(plan) {
37
+ const allDone = plan.steps.length > 0 && plan.steps.every((s) => s.status === 'completed');
38
+ const header = allDone ? `## Done: ${plan.title}` : `## Plan: ${plan.title}`;
39
+ const lines = [header];
40
+ if (plan.goal)
41
+ lines.push(`Goal: ${plan.goal}`);
42
+ lines.push('', '### Steps');
43
+ plan.steps.forEach((s, i) => {
44
+ const box = s.status === 'completed' ? '[x]' : '[ ]';
45
+ const suffix = s.status === 'in_progress'
46
+ ? ` ◀ ${(s.activeForm ?? '').trim() || 'current'}`
47
+ : '';
48
+ lines.push(`- ${box} ${i + 1}. ${s.content}${suffix}`);
49
+ });
50
+ const done = plan.steps.filter((s) => s.status === 'completed').length;
51
+ lines.push('', '### Progress', `- ${done}/${plan.steps.length} steps complete`);
52
+ return lines.join('\n');
53
+ }
54
+ /** 读取当前活跃 `## Plan:` 段的标题;无活跃 plan 返 null。 */
55
+ export function readActivePlanTitle(sessionId = getCurrentSessionId()) {
56
+ const p = getNotesFilePath(sessionId);
57
+ if (!p)
58
+ return null;
59
+ try {
60
+ const normalized = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
61
+ const headerLine = normalized.split('\n').find((l) => /^## Plan:\s*.+$/.test(l));
62
+ return headerLine?.match(/^## Plan:\s*(.+)$/)?.[1].trim() ?? null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * 把 plan 写入 notes.md:替换已有活跃 `## Plan:` 段(原位),否则插到文件顶部;
70
+ * 其它所有笔记段(findings / Open Questions / 已结算 ## Done:)原样保留。
71
+ * 文件不存在则创建(含父目录)。返回 { path, settled } 或 { error }。
72
+ */
73
+ export function writePlanToNotes(plan, sessionId = getCurrentSessionId()) {
74
+ const p = getNotesFilePath(sessionId);
75
+ if (!p)
76
+ return { error: 'no active session' };
77
+ const section = renderPlanSection(plan);
78
+ let existing = '';
79
+ try {
80
+ existing = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
81
+ }
82
+ catch {
83
+ existing = '';
84
+ }
85
+ let next;
86
+ const lines = existing.split('\n');
87
+ const start = lines.findIndex((l) => /^## Plan:\s*.+$/.test(l));
88
+ if (start >= 0) {
89
+ const endOffset = lines.slice(start + 1).findIndex((l) => /^##\s/.test(l));
90
+ const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
91
+ const before = lines.slice(0, start).join('\n').replace(/\s+$/, '');
92
+ const after = lines.slice(end).join('\n').replace(/^\s+/, '');
93
+ next = [before, section, after].filter((s) => s.length > 0).join('\n\n') + '\n';
94
+ }
95
+ else {
96
+ const rest = existing.trim();
97
+ next = rest ? `${section}\n\n${rest}\n` : `${section}\n`;
98
+ }
99
+ try {
100
+ fs.mkdirSync(path.dirname(p), { recursive: true });
101
+ fs.writeFileSync(p, next, 'utf8');
102
+ }
103
+ catch (e) {
104
+ return { error: e instanceof Error ? e.message : String(e) };
105
+ }
106
+ return { path: p, settled: plan.steps.length > 0 && plan.steps.every((s) => s.status === 'completed') };
107
+ }
@@ -12,6 +12,7 @@ import { webSearchTool } from './web-search.js';
12
12
  import { webFetchTool } from './web-fetch.js';
13
13
  import { useSkillTool } from './use-skill.js';
14
14
  import { askHumanTool } from './ask-human.js';
15
+ import { planUpdateTool } from './plan-update.js';
15
16
  import { memorySaveTool } from './memory-save.js';
16
17
  import { memorySearchTool } from './memory-search.js';
17
18
  import { memoryListTool } from './memory-list.js';
@@ -60,6 +61,9 @@ const CAPABILITIES = {
60
61
  web_fetch: { effect: 'network', concurrency: 'parallel', supportsAbort: true },
61
62
  use_skill: { effect: 'read', concurrency: 'serial' },
62
63
  ask_human: { effect: 'read', concurrency: 'serial' },
64
+ // plan_update 只写内部 notes.md(session 工作面),不作为用户代码 mutation 追踪/回滚/diff;
65
+ // 串行即可(调用不频繁),固定资源键让并发调用排队。
66
+ plan_update: { effect: 'write', concurrency: 'serial', resources: () => ['session-notepad'] },
63
67
  memory_save: { effect: 'write', concurrency: 'serial', resources: memoryResource },
64
68
  memory_search: { effect: 'write', concurrency: 'serial', resources: memoryResource },
65
69
  memory_list: { effect: 'read', concurrency: 'serial', resources: memoryResource },
@@ -91,6 +95,7 @@ const rawBuiltinTools = [
91
95
  webFetchTool,
92
96
  useSkillTool,
93
97
  askHumanTool,
98
+ planUpdateTool,
94
99
  ..._memoryTools,
95
100
  subAgentTool,
96
101
  ];
@@ -0,0 +1,144 @@
1
+ import { PLAN_MAX_STEPS, readActivePlanTitle, renderPlanSection, writePlanToNotes, } from '../../session/notes.js';
2
+ const VALID_STATUSES = ['pending', 'in_progress', 'completed'];
3
+ function err(message) {
4
+ return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output: `错误:${message}` };
5
+ }
6
+ /** 归一化模型常见的 status 变体,降低调用摩擦(无损兼容,不补造业务值)。 */
7
+ function normalizeStatus(raw) {
8
+ const s = String(raw ?? 'pending').trim().toLowerCase();
9
+ if (['in_progress', 'in-progress', 'inprogress', 'doing', 'current', 'active', 'wip'].includes(s))
10
+ return 'in_progress';
11
+ if (['completed', 'complete', 'done', 'finished', 'checked'].includes(s))
12
+ return 'completed';
13
+ if (['pending', 'todo', 'to-do', 'open', 'not_started', 'not-started'].includes(s))
14
+ return 'pending';
15
+ return s;
16
+ }
17
+ export const planUpdateTool = {
18
+ name: 'plan_update',
19
+ description: 'Record and update the session execution plan (the `## Plan:` block in `.mocode/sessions/<id>/notes.md`). ' +
20
+ 'Use for any task with 3+ steps or context-loss risk. This REPLACES the whole plan each call, so always pass the full steps array. ' +
21
+ 'Rules: at most one step may be in_progress; mark a step completed as soon as its work is done — do not batch updates to end of turn. ' +
22
+ 'Write each step self-contained enough to survive context compaction: name the target file/symbol, the change, and how to verify. ' +
23
+ 'When every step is completed the plan auto-settles to `## Done:`. Creates notes.md if missing. Safe to call in PLAN mode (writes only the session notepad, never project files).',
24
+ risk: 'safe',
25
+ parameters: {
26
+ type: 'object',
27
+ properties: {
28
+ title: {
29
+ type: 'string',
30
+ description: 'Plan title. Required when creating a plan; omit on update to keep the current title.',
31
+ },
32
+ goal: {
33
+ type: 'string',
34
+ description: 'One-line outcome / definition of done (optional).',
35
+ },
36
+ steps: {
37
+ type: 'array',
38
+ description: `Full replacement step list (1-${PLAN_MAX_STEPS}). At most one step may be in_progress.`,
39
+ items: {
40
+ type: 'object',
41
+ properties: {
42
+ content: {
43
+ type: 'string',
44
+ description: 'Self-contained step: target file/symbol, the change, and how to verify it.',
45
+ },
46
+ status: {
47
+ type: 'string',
48
+ enum: ['pending', 'in_progress', 'completed'],
49
+ description: 'pending | in_progress | completed',
50
+ },
51
+ active_form: {
52
+ type: 'string',
53
+ description: 'Optional present-continuous label shown while in_progress (e.g. "Rewriting parser").',
54
+ },
55
+ },
56
+ required: ['content', 'status'],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ },
61
+ required: ['steps'],
62
+ additionalProperties: false,
63
+ },
64
+ // 兼容模型的字段命名偏差:activeForm→active_form;step 用 text/task/description 代替 content。
65
+ normalizeArguments(args) {
66
+ if (args.activeForm !== undefined && args.active_form === undefined) {
67
+ args.active_form = args.activeForm;
68
+ delete args.activeForm;
69
+ }
70
+ if (Array.isArray(args.steps)) {
71
+ for (const s of args.steps) {
72
+ if (!s || typeof s !== 'object')
73
+ continue;
74
+ const step = s;
75
+ if (step.content === undefined) {
76
+ for (const alt of ['text', 'task', 'description', 'title']) {
77
+ if (typeof step[alt] === 'string') {
78
+ step.content = step[alt];
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ if (step.activeForm !== undefined && step.active_form === undefined) {
84
+ step.active_form = step.activeForm;
85
+ delete step.activeForm;
86
+ }
87
+ if (step.status !== undefined)
88
+ step.status = normalizeStatus(step.status);
89
+ }
90
+ }
91
+ },
92
+ async execute(args) {
93
+ const rawSteps = Array.isArray(args.steps) ? args.steps : null;
94
+ if (!rawSteps || rawSteps.length === 0)
95
+ return err('steps 不能为空(至少 1 步)。');
96
+ if (rawSteps.length > PLAN_MAX_STEPS)
97
+ return err(`steps 最多 ${PLAN_MAX_STEPS} 条,当前 ${rawSteps.length} 条——请合并或拆分阶段。`);
98
+ const steps = [];
99
+ let inProgressCount = 0;
100
+ for (let i = 0; i < rawSteps.length; i++) {
101
+ const raw = rawSteps[i];
102
+ const content = String(raw?.content ?? '').trim();
103
+ if (!content)
104
+ return err(`第 ${i + 1} 步 content 为空。`);
105
+ const status = normalizeStatus(raw?.status);
106
+ if (!VALID_STATUSES.includes(status)) {
107
+ return err(`第 ${i + 1} 步 status 非法:"${String(raw?.status)}"(仅 pending/in_progress/completed)。`);
108
+ }
109
+ if (status === 'in_progress')
110
+ inProgressCount++;
111
+ const activeForm = typeof raw?.active_form === 'string' && raw.active_form.trim()
112
+ ? raw.active_form.trim()
113
+ : undefined;
114
+ steps.push({ content, status, ...(activeForm ? { activeForm } : {}) });
115
+ }
116
+ if (inProgressCount > 1) {
117
+ return err(`同一时刻只能有一个 in_progress 步骤,当前 ${inProgressCount} 个——请把其余改回 pending。`);
118
+ }
119
+ // title:新建必填;更新时缺省则沿用当前活跃 plan 的标题。
120
+ let title = typeof args.title === 'string' ? args.title.trim() : '';
121
+ if (!title) {
122
+ const existing = readActivePlanTitle();
123
+ if (!existing)
124
+ return err('新建 plan 需要提供 title(更新已有 plan 时可省略)。');
125
+ title = existing;
126
+ }
127
+ const goal = typeof args.goal === 'string' && args.goal.trim() ? args.goal.trim() : undefined;
128
+ const plan = { title, ...(goal ? { goal } : {}), steps };
129
+ const result = writePlanToNotes(plan);
130
+ if ('error' in result) {
131
+ return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output: `错误:写入 notes.md 失败: ${result.error}` };
132
+ }
133
+ const done = steps.filter((s) => s.status === 'completed').length;
134
+ const rendered = renderPlanSection(plan);
135
+ const settledNote = result.settled ? '已全部完成,自动结算为 `## Done:`。' : '';
136
+ return {
137
+ status: 'success',
138
+ code: 'OK',
139
+ retryable: false,
140
+ // plan_update 写内部 notes.md,不作为用户代码 mutation 上报 changedFiles。
141
+ output: `已更新执行计划 "${title}"(${done}/${steps.length} 完成)${settledNote}\n\n${rendered}`,
142
+ };
143
+ },
144
+ };
@@ -1,5 +1,5 @@
1
1
  /** 工具共享的截断 / 上限 / 忽略规则。 */
2
- import { isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
2
+ import { isMemoryEnabled, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
3
3
  export const MAX_FILE_LINES = 2000;
4
4
  export const MAX_OUTPUT = 20000;
5
5
  export const MAX_RESULTS = 100;
@@ -23,6 +23,18 @@ export const GC_DAYS = 90;
23
23
  /** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
24
24
  export const MAX_MEMORY_RESULT = 64000;
25
25
  export const IGNORE = ['**/node_modules/**', '**/.git/**'];
26
+ // ── 前端工具簇(默认关闭,显式开启)─────────────────────────────────────────
27
+ /**
28
+ * 前端开发相关工具簇:browser / dev_server 依赖 playwright 二进制且拉起长驻进程,
29
+ * screenshot 抓整个桌面(隐私敏感),view_image 仅视觉模型有用。这 4 个默认不进入
30
+ * 模型工具表,由 isFrontendToolsEnabled() 单一来源控制;/fe on|off 切换。
31
+ */
32
+ export const FRONTEND_TOOLS = new Set([
33
+ 'browser',
34
+ 'dev_server',
35
+ 'screenshot',
36
+ 'view_image',
37
+ ]);
26
38
  // ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
27
39
  /**
28
40
  * plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
@@ -50,15 +62,30 @@ export const PLAN_DISABLED_TOOLS = new Set([
50
62
  * 调用方(agent/core 串行分支、llm/planChatTools)每次 chat 时调本函数拿当前值。
51
63
  */
52
64
  export function getPlanDisabledTools() {
53
- if (isMemoryEnabled())
54
- return PLAN_DISABLED_TOOLS;
55
- const next = new Set(PLAN_DISABLED_TOOLS);
56
- next.delete('memory_save');
57
- next.delete('memory_update');
58
- next.delete('memory_forget');
65
+ const next = isMemoryEnabled()
66
+ ? new Set(PLAN_DISABLED_TOOLS)
67
+ : (() => {
68
+ const n = new Set(PLAN_DISABLED_TOOLS);
69
+ n.delete('memory_save');
70
+ n.delete('memory_update');
71
+ n.delete('memory_forget');
72
+ return n;
73
+ })();
74
+ // 前端工具簇关闭时,plan 模式 schema 也一并剔除(与 auto 模式一致)。
75
+ if (!isFrontendToolsEnabled()) {
76
+ for (const name of FRONTEND_TOOLS)
77
+ next.add(name);
78
+ }
59
79
  return next;
60
80
  }
61
81
  /** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
62
82
  export function getRuntimeDisabledTools() {
63
- return isSubAgentEnabled() ? new Set() : new Set(['sub-agent']);
83
+ const disabled = new Set();
84
+ if (!isSubAgentEnabled())
85
+ disabled.add('sub-agent');
86
+ if (!isFrontendToolsEnabled()) {
87
+ for (const name of FRONTEND_TOOLS)
88
+ disabled.add(name);
89
+ }
90
+ return disabled;
64
91
  }
package/dist/ui/batch.js CHANGED
@@ -249,6 +249,9 @@ export function expandSingleEntryFully(id, layout) {
249
249
  ...buildEntryDetailLines(b.entries[0], entryDetailIndent(b.entries, 0)),
250
250
  ];
251
251
  layout.contentInsertAfter(b.summaryAbsIdx, lines);
252
+ // 修复:contentInsertAfter 不再 commit was-current(避免 collapse 后留孤儿空行);
253
+ // mutation 路径不再由 flushToolBatch 写 \n separator,所以这里手动补一个 \n。
254
+ layout.contentWrite('\n');
252
255
  expandedBatches.add(id);
253
256
  b.expandedEntries.add(0);
254
257
  absLineToEntry.set(b.summaryAbsIdx + 1, { batchId: id, entryIndex: 0 });
@@ -227,18 +227,21 @@ export function lastUserMessageBefore(absStart) {
227
227
  * 若 segMark 活跃且插入点在 segMark.rowIdx 之前,则 segMark.rowIdx 跟着平移(否则
228
228
  * 流式 md 段活跃期间展开/折叠 batch 后,下一个 md chunk 的 setLines 截断到错误位置)。
229
229
  *
230
- * 后置条件:插入后 hasCurrent=false(新空行不由本函数建立);调用方须自行决定续写位
231
- * (BatchRenderer 在插入后调 layout.contentWrite 续写,新 \n 自然在详情块后建新行)。
230
+ * 后置条件:插入后 hasCurrent 保持不变(若进来时 hasCurrent=true,则仍为 true),
231
+ * curRaw 也保持不变 —— cursor 概念上跟到 inserted block 末尾,后续 contentWrite 自然续写。
232
+ * 关键:不再"先 commit 当前行到 rows 末尾再 splice",那样会在 rows 末尾留下
233
+ * 一条 was-current 孤儿空行,在后续 collapse 删除 inserted lines 时无法被对称清掉,
234
+ * 导致下一段文本与该批之间多出 1 行视觉空白(用户报告:「展开又关闭工具信息后
235
+ * 下面空两行」)。现在 leave cursor 不动,由调用方按需补 separator。
236
+ * 例外:expandSingleEntryFully (mutation 自动展开) 在调用本函数后
237
+ * 主动 layout.contentWrite('\n') 写 separator blank,
238
+ * 因为 mutation 不走 flushToolBatch 的 \n。
232
239
  */
233
240
  export function insertAfter(after, lines) {
234
241
  if (lines.length === 0)
235
242
  return;
236
- if (hasCurrent) {
237
- rows.push(rowStartSgr + curRaw + '\x1B[0m');
238
- curRaw = '';
239
- rowStartSgr = curSgr;
240
- hasCurrent = false;
241
- }
243
+ // 修复:不再提交 was-current 行到 rows 末尾(避免后续 collapse 后留孤儿空白)。
244
+ // cursor 留在原位置(指向 spliced block 之后);调用方按需显式 contentWrite('\n') 补 separator。
242
245
  const committed = rows.length;
243
246
  // after 是绝对行索引;若超过 committed(例如快照时 hasCurrent=true),钳到末尾
244
247
  const target = after < 0 ? 0 : Math.min(after + 1, committed);
@@ -254,19 +257,19 @@ export function insertAfter(after, lines) {
254
257
  /**
255
258
  * 从绝对行索引 startIdx(0-based,已 commit)起删 n 行。
256
259
  * 用于「已展开明细折回摘要」——把详情行从中段裁掉,保留摘要行和后续内容。
257
- * startIdx 越界或 n <= 0 直接 no-op。hasCurrent 时先 commit(同 insertAfter)。
260
+ * startIdx 越界或 n <= 0 直接 no-op。
258
261
  *
259
- * 后置条件:删除后 hasCurrent=false。后续 layout.contentWrite 自然续写。
262
+ * 不动当前行(同 insertAfter 修复后的语义):cursor 概念上仍指向 spliced
263
+ * 区间后的同一绝对位置(若 startIdx+1+lines 数 ≥ 新 rows.length,curRaw
264
+ * 代表的就是 spliced 区间内的逻辑行,内容保留)。
265
+ * 后置条件:hasCurrent 与 curRaw 与调用前一致。
260
266
  */
261
267
  export function deleteFrom(startIdx, n) {
262
268
  if (n <= 0)
263
269
  return;
264
- if (hasCurrent) {
265
- rows.push(rowStartSgr + curRaw + '\x1B[0m');
266
- curRaw = '';
267
- rowStartSgr = curSgr;
268
- hasCurrent = false;
269
- }
270
+ // 修复:保持与 insertAfter 对称——不动 hasCurrent/curRaw,避免 splice 后
271
+ // 把 was-current 空行当成普通行 commit 变成孤儿(与 insertAfter 的孤儿
272
+ // bug 是同一个根因的两个对称面)。
270
273
  const committed = rows.length;
271
274
  if (startIdx >= committed)
272
275
  return;