mocode-ai 0.1.4 → 0.1.6

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.
Files changed (59) hide show
  1. package/README.md +196 -188
  2. package/dist/agent/core.js +309 -0
  3. package/dist/agent/index.js +62 -249
  4. package/dist/agent/mode.js +46 -0
  5. package/dist/agent/spawn.js +123 -0
  6. package/dist/commands/config.js +8 -26
  7. package/dist/config/file.js +48 -0
  8. package/dist/config/index.js +81 -36
  9. package/dist/context/classifier.js +83 -0
  10. package/dist/context/encoders/index.js +10 -0
  11. package/dist/context/encoders/passthrough.js +23 -0
  12. package/dist/context/index.js +10 -0
  13. package/dist/context/pipeline.js +84 -0
  14. package/dist/context/registry.js +30 -0
  15. package/dist/context/types.js +12 -0
  16. package/dist/index.js +12 -2
  17. package/dist/llm/index.js +11 -9
  18. package/dist/memory/index.js +2 -2
  19. package/dist/memory/reflect.js +11 -11
  20. package/dist/memory/store.js +4 -4
  21. package/dist/repl/index.js +269 -109
  22. package/dist/sandbox/command.js +41 -0
  23. package/dist/sandbox/index.js +5 -0
  24. package/dist/sandbox/jail.js +75 -0
  25. package/dist/sandbox/policy.js +58 -0
  26. package/dist/sandbox/root.js +19 -0
  27. package/dist/session/compact.js +1 -1
  28. package/dist/skills/index.js +2 -2
  29. package/dist/tools/builtins/ask-human.js +7 -7
  30. package/dist/tools/builtins/codegraph.js +113 -0
  31. package/dist/tools/builtins/edit-file.js +3 -3
  32. package/dist/tools/builtins/glob.js +10 -5
  33. package/dist/tools/builtins/grep.js +14 -9
  34. package/dist/tools/builtins/index.js +7 -1
  35. package/dist/tools/builtins/memory-forget.js +2 -2
  36. package/dist/tools/builtins/memory-list.js +2 -2
  37. package/dist/tools/builtins/memory-save.js +7 -7
  38. package/dist/tools/builtins/memory-search.js +4 -4
  39. package/dist/tools/builtins/memory-update.js +6 -6
  40. package/dist/tools/builtins/read-file.js +5 -4
  41. package/dist/tools/builtins/run-command.js +47 -8
  42. package/dist/tools/builtins/switch-mode.js +46 -0
  43. package/dist/tools/builtins/task.js +62 -0
  44. package/dist/tools/builtins/use-skill.js +2 -2
  45. package/dist/tools/builtins/web-fetch.js +16 -3
  46. package/dist/tools/builtins/web-search.js +6 -6
  47. package/dist/tools/builtins/write-file.js +3 -3
  48. package/dist/tools/constants.js +16 -0
  49. package/dist/tools/registry.js +18 -4
  50. package/dist/ui/content.js +25 -17
  51. package/dist/ui/diff.js +28 -26
  52. package/dist/ui/intervention.js +24 -3
  53. package/dist/ui/layout.js +295 -81
  54. package/dist/ui/markdown.js +607 -0
  55. package/dist/ui/mouse.js +93 -0
  56. package/dist/ui/prompt.js +259 -5
  57. package/dist/ui/render.js +1 -1
  58. package/dist/ui/theme.js +146 -13
  59. package/package.json +2 -2
@@ -1,30 +1,62 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { MAX_OUTPUT } from '../constants.js';
3
+ import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
3
4
  // ---------- run_command ----------
4
5
  export const runCommandTool = {
5
6
  name: 'run_command',
6
- description: '执行 shell 命令,返回合并的 stdout+stderr。默认超时 120 秒。用于跑测试、构建、git 等。',
7
+ description: 'Execute a shell command, returning combined stdout+stderr. Default timeout 120s. Use for running tests, builds, git, etc.',
7
8
  parameters: {
8
9
  type: 'object',
9
10
  properties: {
10
- command: { type: 'string', description: '要执行的命令(单行)' },
11
- timeout: { type: 'integer', description: '超时毫秒,默认120000' },
11
+ command: { type: 'string', description: 'Command to execute (single line)' },
12
+ timeout: { type: 'integer', description: 'Timeout in milliseconds, default 120000' },
12
13
  },
13
14
  required: ['command'],
14
15
  },
15
- async execute(args) {
16
+ async execute(args, ctx) {
16
17
  const command = String(args.command);
17
18
  const timeout = Number(args.timeout ?? 120000);
19
+ // 沙箱 best-effort:灾难性文件操作 denylist(非安全边界,只挡误操作;真隔离需 OS jailer)。
20
+ const deny = isCommandDenied(command);
21
+ if (deny)
22
+ return `错误:${deny}`;
18
23
  return new Promise((done) => {
19
24
  const isWin = process.platform === 'win32';
20
- const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: process.cwd() });
25
+ // 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
26
+ const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
21
27
  let out = '';
22
28
  let finished = false;
29
+ let timer;
30
+ // 杀整棵进程树。child.kill() 在 Windows 只杀 cmd.exe、npm 等子进程会孤儿继续跑(占锁、污染下一步),
31
+ // 故 Win 用 taskkill /T /F 树杀;Unix child.kill('SIGTERM')(bash -c 通常转发给前台子进程,best-effort)。
32
+ const killTree = () => {
33
+ try {
34
+ if (isWin) {
35
+ if (child.pid != null) {
36
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
37
+ stdio: 'ignore',
38
+ });
39
+ }
40
+ }
41
+ else {
42
+ child.kill('SIGTERM');
43
+ }
44
+ }
45
+ catch {
46
+ // 进程已退出 / kill 失败:忽略(close 事件会兜底 finish)
47
+ }
48
+ };
49
+ // abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
50
+ const onAbort = () => {
51
+ killTree();
52
+ finish(`[已中断]\n${out.trim()}`);
53
+ };
23
54
  const finish = (s) => {
24
55
  if (finished)
25
56
  return;
26
57
  finished = true;
27
58
  clearTimeout(timer);
59
+ ctx?.signal?.removeEventListener('abort', onAbort);
28
60
  done(s);
29
61
  };
30
62
  const onChunk = (chunk) => {
@@ -40,10 +72,17 @@ export const runCommandTool = {
40
72
  r += '\n...(输出已截断)';
41
73
  finish(`[退出码 ${code}]\n${r || '(无输出)'}`);
42
74
  });
43
- const timer = setTimeout(() => {
44
- child.kill();
75
+ timer = setTimeout(() => {
76
+ killTree();
45
77
  finish(`[超时,已终止]\n${out.trim()}`);
46
78
  }, timeout);
79
+ // 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
80
+ if (ctx?.signal) {
81
+ if (ctx.signal.aborted)
82
+ onAbort();
83
+ else
84
+ ctx.signal.addEventListener('abort', onAbort, { once: true });
85
+ }
47
86
  });
48
87
  },
49
88
  };
@@ -0,0 +1,46 @@
1
+ import { setAgentMode, getAgentMode } from '../../agent/mode.js';
2
+ // ---------- switch_mode ----------
3
+ export const switchModeTool = {
4
+ name: 'switch_mode',
5
+ description: [
6
+ 'Switch the agent mode between "plan" (read-only investigation + planning) and "auto" (full tool execution).',
7
+ 'In plan mode, write_file / edit_file / run_command / memory_save / memory_update / memory_forget are disabled; in auto mode, all tools are available.',
8
+ 'Use this to autonomously transition from planning to execution WITHIN THE SAME TURN: investigate in plan mode, produce a plan, then call switch_mode("auto") and continue implementing it in the same turn — ONLY when the user has explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution.',
9
+ 'Do NOT switch to auto if the user entered plan mode manually (via /plan or Shift+Tab) for a safety review — in that case present the plan and STOP; the user will approve via a prompt and execution happens in a follow-up turn.',
10
+ 'Switching to plan from auto is rarely needed; do it only if you realize you should investigate before changing anything.',
11
+ ].join(' '),
12
+ parameters: {
13
+ type: 'object',
14
+ properties: {
15
+ mode: {
16
+ type: 'string',
17
+ enum: ['auto', 'plan'],
18
+ description: 'Target mode: "plan" (read-only) or "auto" (full tools)',
19
+ },
20
+ },
21
+ required: ['mode'],
22
+ },
23
+ async execute(args) {
24
+ const target = String(args.mode);
25
+ if (target !== 'auto' && target !== 'plan') {
26
+ return `错误:mode 必须是 "auto" 或 "plan",收到 "${target}"`;
27
+ }
28
+ const prev = getAgentMode();
29
+ if (prev === target) {
30
+ return `已在 ${target} 模式,无需切换。`;
31
+ }
32
+ setAgentMode(target); // 触发 listener:repl 重写 history[0] 系统提示 + 刷状态行 modeTag
33
+ if (target === 'auto') {
34
+ return [
35
+ `已从 plan 切换到 auto 模式。`,
36
+ `写盘 / 命令 / 记忆写入工具(write_file / edit_file / run_command / memory_save / memory_update / memory_forget)现已恢复可用。`,
37
+ `请按你刚才产出的计划继续执行(同一轮内,无需重述计划)。`,
38
+ ].join('\n');
39
+ }
40
+ return [
41
+ `已从 auto 切换到 plan 模式(只读探查)。`,
42
+ `写盘 / 命令 / 记忆写入工具已禁用;只用 read_file / glob / grep / codegraph / web_search / web_fetch / use_skill / ask_human / memory_search / memory_list 探查。`,
43
+ `产出计划后 STOP 等用户审批,或(若用户要求自主执行)再调 switch_mode("auto") 续跑。`,
44
+ ].join('\n');
45
+ },
46
+ };
@@ -0,0 +1,62 @@
1
+ import { spawnAgent } from '../../agent/spawn.js';
2
+ import { MAX_OUTPUT } from '../constants.js';
3
+ // ---------- task ----------
4
+ // 派生子 agent 执行独立子任务。子 agent 有独立 history(不污染主对话),
5
+ // 可受限工具子集 + 低步数上限,最终摘要回灌主 history 供主 agent 继续。
6
+ //
7
+ // 适用:并行探查多个文件 / 分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
8
+ // 子 agent 中间过程不写主屏,只返回最终摘要;主 agent 据摘要决定下一步。
9
+ export const taskTool = {
10
+ name: 'task',
11
+ description: [
12
+ 'Spawn a sub-agent to handle an isolated sub-task with its own conversation history (independent of the main thread). The sub-agent runs to completion and returns a concise summary.',
13
+ 'Use when: a task can be decomposed into independent sub-tasks, you want to explore multiple files/areas without polluting the main history, or a sub-task involves many tool calls that would bloat the main context window.',
14
+ 'The sub-agent has its own history; only its final summary is returned to you as the tool result. It cannot recursively spawn further sub-agents (no "task" tool available to it).',
15
+ 'Optionally restrict the sub-agent to a subset of tools (e.g. read-only tools for pure investigation) via the "tools" parameter.',
16
+ ].join(''),
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ prompt: {
21
+ type: 'string',
22
+ description: 'The sub-task instruction for the sub-agent. Be specific about what to investigate/implement and what to report back.',
23
+ },
24
+ tools: {
25
+ type: 'array',
26
+ items: { type: 'string' },
27
+ description: 'Optional whitelist of tool names the sub-agent is allowed to use (e.g. ["read_file","glob","grep","codegraph"] for read-only investigation). Omit to allow all tools.',
28
+ },
29
+ maxSteps: {
30
+ type: 'number',
31
+ description: 'Optional step limit for the sub-agent (default 50). Lower if the sub-task should be quick.',
32
+ },
33
+ },
34
+ required: ['prompt'],
35
+ },
36
+ async execute(args, ctx) {
37
+ const prompt = String(args.prompt ?? '');
38
+ if (!prompt)
39
+ return '错误:缺少 prompt(子任务指令)。';
40
+ const tools = Array.isArray(args.tools)
41
+ ? args.tools.map((t) => String(t))
42
+ : undefined;
43
+ const maxSteps = typeof args.maxSteps === 'number' && args.maxSteps > 0
44
+ ? Math.floor(args.maxSteps)
45
+ : undefined;
46
+ // 透传主 agent 的 abort signal:主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)。
47
+ const result = await spawnAgent({ prompt, tools, maxSteps, signal: ctx?.signal });
48
+ if (!result.completed) {
49
+ return '子 agent 被中断,未完成。';
50
+ }
51
+ if (!result.summary) {
52
+ return '子 agent 完成但未返回文本摘要(可能只调了工具或达到步数上限)。';
53
+ }
54
+ // 摘要可能很长,截到 MAX_OUTPUT 保主 history 不爆。
55
+ const summary = result.summary;
56
+ if (summary.length > MAX_OUTPUT) {
57
+ return (summary.slice(0, MAX_OUTPUT) +
58
+ `\n\n…(子 agent 摘要已截断 ${summary.length - MAX_OUTPUT} 字符)`);
59
+ }
60
+ return summary;
61
+ },
62
+ };
@@ -4,13 +4,13 @@ import { getSkillBody } from '../../skills/index.js';
4
4
  // 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
5
5
  export const useSkillTool = {
6
6
  name: 'use_skill',
7
- description: '加载并返回某个 skill 的完整 SKILL.md 指令(传 name)。何时用见系统提示的 skill 列表。',
7
+ description: 'Load and return the full SKILL.md instructions for a given skill (pass name). See the skill list in the system prompt for when to use each.',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
11
11
  name: {
12
12
  type: 'string',
13
- description: '要加载的 skill 名(见系统提示里的 skill 列表,或 /skills 命令)',
13
+ description: 'Name of the skill to load (see the skill list in the system prompt, or the /skills command)',
14
14
  },
15
15
  },
16
16
  required: ['name'],
@@ -4,15 +4,15 @@ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
4
4
  // ---------- web_fetch ----------
5
5
  export const webFetchTool = {
6
6
  name: 'web_fetch',
7
- description: '抓取指定 URL 的网页内容并清洗成纯文本(去 HTML 标签/脚本/样式,保留正文)。用于读取搜索结果里的某个链接、或用户给出的具体 URL。',
7
+ description: 'Fetch the web page at a given URL and clean it into plain text (strips HTML tags/scripts/styles, keeps the body). Use to read a link from search results, or a specific URL given by the user.',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
11
- url: { type: 'string', description: '要抓取的完整 URL,须 http/https' },
11
+ url: { type: 'string', description: 'Full URL to fetch; must be http/https' },
12
12
  },
13
13
  required: ['url'],
14
14
  },
15
- async execute(args) {
15
+ async execute(args, ctx) {
16
16
  const rawUrl = String(args.url ?? '').trim();
17
17
  if (!rawUrl)
18
18
  return '错误:url 不能为空。';
@@ -28,6 +28,15 @@ export const webFetchTool = {
28
28
  }
29
29
  const ctrl = new AbortController();
30
30
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
31
+ // 外部 abort signal(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 合并到 ctrl,fetch 即时取消(不等 30s 超时)
32
+ const onExternalAbort = () => ctrl.abort();
33
+ const externalSignal = ctx?.signal;
34
+ if (externalSignal) {
35
+ if (externalSignal.aborted)
36
+ ctrl.abort();
37
+ else
38
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
39
+ }
31
40
  try {
32
41
  const resp = await fetch(url.href, {
33
42
  method: 'GET',
@@ -56,6 +65,8 @@ export const webFetchTool = {
56
65
  }
57
66
  catch (e) {
58
67
  if (ctrl.signal.aborted) {
68
+ if (externalSignal?.aborted)
69
+ return `错误:已中断: ${url.href}`;
59
70
  return `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`;
60
71
  }
61
72
  const msg = e instanceof Error ? e.message : String(e);
@@ -63,6 +74,8 @@ export const webFetchTool = {
63
74
  }
64
75
  finally {
65
76
  clearTimeout(timer);
77
+ if (externalSignal)
78
+ externalSignal.removeEventListener('abort', onExternalAbort);
66
79
  }
67
80
  },
68
81
  };
@@ -6,20 +6,20 @@ const MAX_CONTENT_CHARS = 800;
6
6
  // ---------- web_search ----------
7
7
  export const webSearchTool = {
8
8
  name: 'web_search',
9
- description: '联网搜索(AnySearch)。返回每条结果的标题/URL/摘要/正文。可选 tag 切换子域(见 tag 参数)。',
9
+ description: 'Search the web (AnySearch). Returns title/url/snippet/body for each result. Optional tag to switch sub-domain capability (see tag param).',
10
10
  parameters: {
11
11
  type: 'object',
12
12
  properties: {
13
- query: { type: 'string', description: '搜索查询词' },
14
- max_results: { type: 'integer', description: '返回条数,1-20,默认 10' },
13
+ query: { type: 'string', description: 'Search query' },
14
+ max_results: { type: 'integer', description: 'Number of results, 1-20, default 10' },
15
15
  tag: {
16
16
  type: 'string',
17
- description: '子域能力标签,如 general.general / code.doc / code.snippet;不传走通用搜索',
17
+ description: 'Sub-domain capability tag, e.g. general.general / code.doc / code.snippet; omit for general search',
18
18
  },
19
- language: { type: 'string', description: '偏好语言,如 zh-CN / en,默认 zh-CN' },
19
+ language: { type: 'string', description: 'Preferred language, e.g. zh-CN / en, default zh-CN' },
20
20
  params: {
21
21
  type: 'object',
22
- description: '特定 tag 的扩展参数,如 code.doc 的 {"library":"golang"};通用搜索不需要',
22
+ description: 'Extended params for a specific tag, e.g. {"library":"golang"} for code.doc; not needed for general search',
23
23
  },
24
24
  },
25
25
  required: ['query'],
@@ -3,12 +3,12 @@ import { resolve, dirname } from 'node:path';
3
3
  // ---------- write_file ----------
4
4
  export const writeFileTool = {
5
5
  name: 'write_file',
6
- description: '创建或覆盖文件,自动创建父目录。',
6
+ description: 'Create or overwrite a file, creating parent directories as needed.',
7
7
  parameters: {
8
8
  type: 'object',
9
9
  properties: {
10
- path: { type: 'string', description: '文件路径' },
11
- content: { type: 'string', description: '完整文件内容' },
10
+ path: { type: 'string', description: 'File path' },
11
+ content: { type: 'string', description: 'Full file content' },
12
12
  },
13
13
  required: ['path', 'content'],
14
14
  },
@@ -22,3 +22,19 @@ export const GC_DAYS = 90;
22
22
  /** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
23
23
  export const MAX_MEMORY_RESULT = 64000;
24
24
  export const IGNORE = ['**/node_modules/**', '**/.git/**'];
25
+ // ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
26
+ /**
27
+ * plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
28
+ * 写盘 / 命令 / 记忆写入类 + task(派生子 agent,plan 模式只读不可有副作用)。单一事实源,
29
+ * 被 llm(planChatTools)与 agent(防御 backstop)共用。
30
+ * 只读工具(read_file/glob/grep/codegraph/web_search/web_fetch/use_skill/ask_human/memory_search/memory_list)保留。
31
+ */
32
+ export const PLAN_DISABLED_TOOLS = new Set([
33
+ 'write_file',
34
+ 'edit_file',
35
+ 'run_command',
36
+ 'memory_save',
37
+ 'memory_update',
38
+ 'memory_forget',
39
+ 'task',
40
+ ]);
@@ -1,12 +1,18 @@
1
1
  import { builtinTools } from './builtins/index.js';
2
2
  import { recordMutation } from '../rollback/index.js';
3
+ import { enforceSandbox } from '../sandbox/index.js';
3
4
  /**
4
5
  * 工具注册表。当前 = 内置工具;
5
6
  * 未来可在此合并 MCP 工具、用户自定义工具等(见 src/mcp/)。
6
7
  */
7
8
  export const tools = builtinTools;
8
- /** 按名调度工具,统一 try/catch + JSON 解析,返回字符串而非抛错。 */
9
- export async function executeTool(name, argsRaw) {
9
+ /**
10
+ * 按名调度工具,统一 try/catch + JSON 解析,返回字符串而非抛错。
11
+ * signal 透传给 tool.execute(经 ctx):长任务工具(run_command/web_fetch)abort 即时取消,
12
+ * 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
13
+ * opts.skipRollback:子 agent 逻辑隔离用——跳过 recordMutation,子 agent 改动不进主回滚快照链。
14
+ */
15
+ export async function executeTool(name, argsRaw, signal, opts) {
10
16
  const tool = tools.find((t) => t.name === name);
11
17
  if (!tool)
12
18
  return `错误:未知工具 "${name}"`;
@@ -18,13 +24,21 @@ export async function executeTool(name, argsRaw) {
18
24
  return `错误:工具 ${name} 的 arguments 不是合法 JSON: ${argsRaw}`;
19
25
  }
20
26
  try {
27
+ // 沙箱:路径类工具(读/写/改)越界拒绝 + args.path 重写为牢内绝对;glob/grep pattern 校验。
28
+ // 返 string = 拒绝(直接喂 LLM,不执行);返 null = 放行(可能已重写 args.path)。
29
+ // 须在 recordMutation 前:让快照路径 = 牢内绝对路径,与回滚一致。不抛(契约:调度器永不抛错)。
30
+ const sbErr = enforceSandbox(name, args);
31
+ if (sbErr)
32
+ return sbErr;
21
33
  // 撤销回滚用:write_file/edit_file 改动前记 before 快照(回滚时恢复到轮末状态)。
22
- if ((name === 'write_file' || name === 'edit_file') &&
34
+ // 子 agent(skipRollback)跳过:其改动不进主回滚链,主 /rollback 不撤销(靠 git 兜底)。
35
+ if (!opts?.skipRollback &&
36
+ (name === 'write_file' || name === 'edit_file') &&
23
37
  typeof args.path === 'string' &&
24
38
  args.path) {
25
39
  recordMutation(args.path);
26
40
  }
27
- return await tool.execute(args);
41
+ return await tool.execute(args, { signal, skipRollback: opts?.skipRollback });
28
42
  }
29
43
  catch (e) {
30
44
  return `错误:工具 ${name} 执行失败: ${e instanceof Error ? e.message : String(e)}`;
@@ -2,7 +2,7 @@
2
2
  * 内容物理行缓冲(Phase 2 滚动回滚用)。
3
3
  *
4
4
  * 存折行后的物理行,每行自洽带色,供 layout.repaintViewport 单行直出、无需继承 SGR 状态。
5
- * contentWrite 逐字符喂入(feedChar / feedSgr / breakRow);eraseSegmentBack 用 popRows 同步删段。
5
+ * contentWrite 逐字符喂入(feedChar / feedSgr / breakRow);setLines 替换当前段(md 流式渲染用)。
6
6
  *
7
7
  * **SGR 自洽模型**:每行存 `rowStartSgr + curRaw + \x1B[0m`:
8
8
  * - rowStartSgr:本行起始时继承的活跃 SGR 前缀(来自上一行末状态);
@@ -23,7 +23,7 @@ let curSgr = ''; // 当前活跃 SGR 状态(行内演进;行末值 = 下行继
23
23
  let rowStartSgr = ''; // 当前行起始继承的 SGR 前缀
24
24
  let curRaw = ''; // 当前行原始字节(可见 + 行内 SGR 码)
25
25
  let hasCurrent = false; // 是否有当前行(feed/breakRow 后;新空行也算)
26
- let segMark = null; // 段起点快照(供 eraseSegment 恢复)
26
+ let segMark = null; // 段起点快照(md 段:setLines 截断用,commitSegment 清)
27
27
  export function reset() {
28
28
  rows = [];
29
29
  curSgr = '';
@@ -55,27 +55,35 @@ export function breakRow() {
55
55
  rowStartSgr = curSgr; // 下行继承本行末状态
56
56
  hasCurrent = true; // 新空行即当前行
57
57
  }
58
- /** 标记段(思考段)起点:快照当前缓冲状态,供 eraseSegment 恢复。 */
58
+ /** 标记段起点:快照当前缓冲状态,供 setLines 截断定位段头(md 流式渲染每 chunk 截断重渲)。 */
59
59
  export function beginSegment() {
60
60
  segMark = { rowIdx: rows.length, rowStartSgr, curSgr, curRaw, hasCurrent };
61
61
  }
62
62
  /**
63
- * 删段并恢复到 beginSegment 时的状态;返回段物理行数(= segLines + 末行部分,与屏幕擦除行数一致)。
64
- * 段足迹 = 自 mark 后提交的行(committedErased)+ 当前行若有内容(currentErased)。
65
- * 恢复后当前行 = 段起点的那一行(折叠标题将写入此处),保证滚动回看只看到标题、不看到原文。
63
+ * 用预渲染的自洽 ANSI 行替换当前段(从 segMark 起),供 markdown 流式渲染:
64
+ * layout.contentWriteMd 每 chunk 把累积 text 经 renderMarkdown 渲成自洽行,调此替换缓冲段。
65
+ * 截断 rows 到 segMark.rowIdx(擦上次渲染)+ push 新行;curSgr 复位为默认(行末 reset);
66
+ * **保留 segMark**(下次 setLines 还要截断),由 commitSegment 清除。
67
+ * 行须各自自洽(行内 SGR 自带、行末 reset)——不经 feedChar/feedSgr,直接入 rows,
68
+ * 故 setLines 后 hasCurrent=false(snapshot 只返 rows;续写位由 layout 跟踪)。
66
69
  */
67
- export function eraseSegment() {
68
- if (!segMark)
69
- return 0;
70
- const committedErased = rows.length - segMark.rowIdx;
71
- const currentErased = hasCurrent && curRaw.length > 0 ? 1 : 0;
72
- rows.length = segMark.rowIdx;
73
- rowStartSgr = segMark.rowStartSgr;
74
- curSgr = segMark.curSgr;
75
- curRaw = segMark.curRaw;
76
- hasCurrent = segMark.hasCurrent;
70
+ export function setLines(lines) {
71
+ if (segMark) {
72
+ rows.length = segMark.rowIdx;
73
+ }
74
+ for (const line of lines) {
75
+ rows.push(line);
76
+ }
77
+ if (rows.length > MAX_ROWS + 512)
78
+ rows.splice(0, rows.length - MAX_ROWS);
79
+ curSgr = '';
80
+ rowStartSgr = '';
81
+ curRaw = '';
82
+ hasCurrent = false;
83
+ }
84
+ /** 提交段:清 segMark,后续写入不再被 setLines 截断(md 段结束、非 md 内容接续时调)。 */
85
+ export function commitSegment() {
77
86
  segMark = null;
78
- return committedErased + currentErased;
79
87
  }
80
88
  /** 快照(已提交行 + 当前行若有)。viewport 取行窗用。 */
81
89
  function snapshot() {
package/dist/ui/diff.js CHANGED
@@ -36,35 +36,37 @@ function langForPath(p) {
36
36
  }
37
37
  return LANG_BY_EXT[base];
38
38
  }
39
- /** 包一层:token 文本 + 颜色 + reset,自成闭区间(防 SGR 跨行泄漏)。 */
40
- const paint = (color) => (s) => `${color}${s}${ui.reset}`;
39
+ /**
40
+ * 包一层:token 文本 + 颜色 + reset,自成闭区间(防 SGR 跨行泄漏)。
41
+ * 取 ColorKey 而非 ANSI 串——闭包调用时才读 `ui[key]`,故 setTheme 后 diff 高亮即跟随。 */
42
+ const paint = (key) => (s) => `${ui[key]}${s}${ui.reset}`;
41
43
  /** 自定义主题:default=plain(未匹配文本不着色,避免默认主题把普通代码染黄),常见 token 用 mocode ui 色板。 */
42
44
  const THEME = {
43
45
  default: plain,
44
- keyword: paint(ui.magenta),
45
- 'built_in': paint(ui.cyan),
46
- type: paint(ui.cyan),
47
- literal: paint(ui.cyan),
48
- number: paint(ui.green),
49
- string: paint(ui.green),
50
- subst: paint(ui.green),
51
- comment: paint(ui.gray),
52
- doctag: paint(ui.gray),
53
- function: paint(ui.blue),
54
- title: paint(ui.blue),
55
- class: paint(ui.brightCyan),
56
- meta: paint(ui.gray),
57
- 'meta-keyword': paint(ui.magenta),
58
- regexp: paint(ui.red),
59
- attr: paint(ui.cyan),
60
- attribute: paint(ui.cyan),
61
- variable: paint(ui.red),
62
- tag: paint(ui.red),
63
- name: paint(ui.cyan),
64
- symbol: paint(ui.cyan),
65
- section: paint(ui.brightMagenta),
66
- addition: paint(ui.green),
67
- deletion: paint(ui.red),
46
+ keyword: paint('magenta'),
47
+ 'built_in': paint('cyan'),
48
+ type: paint('cyan'),
49
+ literal: paint('cyan'),
50
+ number: paint('green'),
51
+ string: paint('green'),
52
+ subst: paint('green'),
53
+ comment: paint('gray'),
54
+ doctag: paint('gray'),
55
+ function: paint('blue'),
56
+ title: paint('blue'),
57
+ class: paint('brightCyan'),
58
+ meta: paint('gray'),
59
+ 'meta-keyword': paint('magenta'),
60
+ regexp: paint('red'),
61
+ attr: paint('cyan'),
62
+ attribute: paint('cyan'),
63
+ variable: paint('red'),
64
+ tag: paint('red'),
65
+ name: paint('cyan'),
66
+ symbol: paint('cyan'),
67
+ section: paint('brightMagenta'),
68
+ addition: paint('green'),
69
+ deletion: paint('red'),
68
70
  };
69
71
  function highlightLine(text, lang) {
70
72
  if (!lang || text === '')
@@ -3,6 +3,7 @@ import { stdin, stderr } from 'node:process';
3
3
  import { ui } from './theme.js';
4
4
  import { displayWidth, truncateDisplay } from './render.js';
5
5
  import * as layout from './layout.js';
6
+ import * as mouse from './mouse.js';
6
7
  import { Spinner } from './spinner.js';
7
8
  const emitter = stdin;
8
9
  /** choice 末尾的"自定义输入"项标签(纯 ASCII,宽度安全)。选中后切到 input 子态。 */
@@ -128,8 +129,9 @@ export async function promptIntervention(req) {
128
129
  emitter.on('keypress', l);
129
130
  savedListeners = [];
130
131
  // 先 paintInput(dim,menu:null):擦菜单(用 lastMenuRows)+ 画 dim 占位底栏 + 复位 lastMenuRows=0;
131
- // 再 repaintViewport:从 content 缓冲重画内容区(恢复被菜单覆盖的 ● ask_human 行)。
132
- // 顺序不可反——repaintViewport 后再 paintInput(menu:null) 会用 stale lastMenuRows 擦掉已恢复的内容。
132
+ // 再处理滚动:面板内可能滚过滚轮(scrollOffset>0)→ resetScroll 回尾让 agent 后续输出可见;
133
+ // 未滚则 repaintViewport 从 content 缓冲重画内容区(恢复被菜单覆盖的 ● ask_human 行)。
134
+ // 顺序不可反——resetScroll/repaintViewport 在菜单还在(lastMenuRows>0)时会用内容覆盖菜单区,擦不掉菜单。
133
135
  layout.paintInput({
134
136
  prompt: '❯ ',
135
137
  lines: [''],
@@ -138,11 +140,24 @@ export async function promptIntervention(req) {
138
140
  menu: null,
139
141
  dim: true,
140
142
  });
141
- layout.repaintViewport();
143
+ if (layout.isScrolled())
144
+ layout.resetScroll();
145
+ else
146
+ layout.repaintViewport();
142
147
  }
143
148
  function onKey(_str, key) {
144
149
  if (resolved || !key)
145
150
  return;
151
+ // SGR 鼠标 fragment:readline 拆碎 \x1B[<…M,经 mouse.consumeMouse 重组;
152
+ // 滚轮 → scrollBy(±5) 滚动查看 agent 输出内容(菜单只靠 ↑↓ 选,滚轮不选菜单);
153
+ // fragment 期间 suppress 吞掉防砸进 onKeyInput 的可打印插入。菜单经 scrollBy 末尾
154
+ // repaint()→paintInput(lastView) 重画,不被 viewport 滚动覆盖。
155
+ const _m = mouse.consumeMouse(key.sequence ?? '');
156
+ if (_m.suppress) {
157
+ if (_m.wheel)
158
+ layout.scrollBy(_m.wheel * 5);
159
+ return;
160
+ }
146
161
  // Ctrl+C → 取消(不 reject SIGINT——否则经 executeTool 的 try/catch 变成 tool 错误串)
147
162
  if (key.ctrl && key.name === 'c') {
148
163
  finish({ action: 'cancelled' });
@@ -158,6 +173,12 @@ export async function promptIntervention(req) {
158
173
  finish({ action: 'cancelled' });
159
174
  return;
160
175
  }
176
+ // PgUp/PgDn:翻页滚动查看 agent 输出内容(与 onRunningKey/prompt.ts 一致;↑↓ 仍归菜单导航,不抢)。
177
+ if (key.name === 'pageup' || key.name === 'pagedown') {
178
+ const pageH = layout.getGeo().contentBottom;
179
+ layout.scrollBy(key.name === 'pageup' ? pageH : -pageH);
180
+ return;
181
+ }
161
182
  if (mode === 'choice') {
162
183
  onKeyChoice(key);
163
184
  }