mocode-ai 1.7.2 → 1.7.4

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 (43) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/config/index.js +1 -1
  4. package/dist/config/profiles.js +2 -2
  5. package/dist/flows/cli.js +168 -0
  6. package/dist/flows/flow.js +483 -0
  7. package/dist/flows/runner.js +259 -0
  8. package/dist/flows/trace.js +242 -0
  9. package/dist/i18n/index.js +28 -0
  10. package/dist/index.js +7 -0
  11. package/dist/llm/index.js +1 -1
  12. package/dist/permissions/computer-review.js +40 -0
  13. package/dist/permissions/flow-review.js +41 -0
  14. package/dist/permissions/index.js +47 -28
  15. package/dist/repl/commands/flow.js +210 -0
  16. package/dist/repl/commands/registry.js +2 -0
  17. package/dist/repl/commands/tool-group.js +9 -1
  18. package/dist/repl/commands.js +10 -0
  19. package/dist/repl/running-input.js +75 -14
  20. package/dist/repl/runtime.js +4 -1
  21. package/dist/runtime/input-injector.js +39 -26
  22. package/dist/runtime/shutdown.js +3 -1
  23. package/dist/runtime/uia-selector.js +324 -0
  24. package/dist/runtime/uia-service.js +510 -0
  25. package/dist/runtime/wait-until.js +199 -0
  26. package/dist/runtime/window-list.js +164 -0
  27. package/dist/session/gui-actions.js +47 -0
  28. package/dist/tools/builtins/computer.js +647 -10
  29. package/dist/tools/builtins/index.js +4 -0
  30. package/dist/tools/builtins/run-flow.js +123 -0
  31. package/dist/tools/constants.js +1 -0
  32. package/dist/tools/router.js +1 -1
  33. package/dist/tools/validation.js +57 -0
  34. package/dist/ui/composer.js +6 -2
  35. package/dist/ui/layout-internal/content-write.js +74 -6
  36. package/dist/ui/layout-internal/core.js +32 -8
  37. package/dist/ui/layout-internal/screen.js +4 -0
  38. package/dist/ui/layout-internal/scroll.js +5 -3
  39. package/dist/ui/layout-internal/state.js +6 -0
  40. package/dist/ui/layout-internal/statusbar.js +16 -1
  41. package/dist/ui/render.js +19 -11
  42. package/docs/usage.md +344 -344
  43. package/package.json +92 -92
@@ -25,6 +25,7 @@ import { subAgentTool } from './task.js';
25
25
  import { arenaTool } from './arena.js';
26
26
  import { messageBusTool } from './message-bus.js';
27
27
  import { computerTool } from './computer.js';
28
+ import { runFlowTool } from './run-flow.js';
28
29
  import { sessionSearchTool } from './session-search.js';
29
30
  /**
30
31
  * 所有内置工具,按注册顺序排列。
@@ -86,6 +87,8 @@ export const CAPABILITIES = {
86
87
  // computer 桌面操控:桌面状态全局唯一,任何两个 computer 调用都不允许并发;
87
88
  // effect=process(不写工作区文件,不触发 rollback/diff 追踪),supportsAbort 中断长 wait/拖拽。
88
89
  computer: { effect: 'process', concurrency: 'serial', resources: () => ['desktop'], supportsAbort: true },
90
+ // run_flow 回放录制的桌面流程:内部直接驱动 computer,同样独占 desktop 资源,可中断。
91
+ run_flow: { effect: 'process', concurrency: 'serial', resources: () => ['desktop'], supportsAbort: true },
89
92
  // sub-agent 与主 agent 同权,直接写工作区(无 overlay)。编排器本身不持锁——锁由嵌套工具
90
93
  // 各自获取,否则子 agent 内的 run_command 会等父持有的 workspace 锁而自锁。
91
94
  // parallelOrchestration:同一轮派发的多个子 agent 按 subAgentConcurrency 上限成批并行
@@ -133,6 +136,7 @@ const rawBuiltinTools = [
133
136
  arenaTool,
134
137
  messageBusTool,
135
138
  computerTool,
139
+ runFlowTool,
136
140
  sessionSearchTool,
137
141
  ];
138
142
  /** 所有内置工具均携带显式能力;新增工具遗漏声明时 registry 会保守串行。 */
@@ -0,0 +1,123 @@
1
+ /**
2
+ * run_flow 工具:回放 `.mocode/flows/<name>.json` 里录制好的桌面流程(design-notes/computer-use-rpa.md §5.3)。
3
+ *
4
+ * 与逐步调用 computer 的区别:整条流程不经过模型,按录制的 selector 现场定位元素,失败按步骤的
5
+ * onError 策略重试 / 跳过 / 中止 / 交还模型。权限在整条 flow 层面一次做完(permissions/flow-review.ts):
6
+ * 指纹绑定 flow 文件 hash,敏感 flow 强制逐次确认——回放内部的单步不再弹窗。
7
+ */
8
+ import { runFlow, formatFlowRunResult, } from '../../flows/runner.js';
9
+ import { loadFlow } from '../../flows/flow.js';
10
+ import { withTraceSuspended } from '../../flows/trace.js';
11
+ import { computerTool, resolveSelectorToRef, resolveWindowForReplay } from './computer.js';
12
+ function abortableSleep(ms, signal) {
13
+ return new Promise((resolve, reject) => {
14
+ if (signal?.aborted) {
15
+ reject(new Error('aborted'));
16
+ return;
17
+ }
18
+ const onAbort = () => {
19
+ clearTimeout(timer);
20
+ reject(new Error('aborted'));
21
+ };
22
+ const timer = setTimeout(() => {
23
+ signal?.removeEventListener('abort', onAbort);
24
+ resolve();
25
+ }, ms);
26
+ signal?.addEventListener('abort', onAbort, { once: true });
27
+ });
28
+ }
29
+ /** 生产环境的 RunnerDeps:把 runner 接到 computer 工具与 UIA 定位上。 */
30
+ export function createComputerRunnerDeps(signal) {
31
+ return {
32
+ async execute(args, sig) {
33
+ const r = await computerTool.execute(args, { signal: sig });
34
+ if (typeof r === 'string')
35
+ return { status: 'success', output: r };
36
+ return { status: r.status, code: r.code, output: r.output, modelAttachments: r.modelAttachments };
37
+ },
38
+ resolveRef: resolveSelectorToRef,
39
+ resolveWindow: resolveWindowForReplay,
40
+ sleep: abortableSleep,
41
+ signal,
42
+ };
43
+ }
44
+ /** run_flow 工具与 `/flow run` 共用的回放入口;回放期间暂停 trace 录制。 */
45
+ export function runFlowWithComputer(flow, params, signal, options) {
46
+ return withTraceSuspended(() => runFlow(flow, params, createComputerRunnerDeps(signal), options));
47
+ }
48
+ export const runFlowTool = {
49
+ name: 'run_flow',
50
+ description: 'Replay a recorded desktop flow (.mocode/flows/<name>.json) without step-by-step model calls. ' +
51
+ 'Elements are re-located by their recorded UI Automation selector, so the flow survives moved windows; ' +
52
+ 'steps without a selector use recorded coordinates and are only as reliable as the window layout. ' +
53
+ 'A failing step is retried, then skipped, aborted, or handed back to you (default: retry once, then hand back with the current screenshot). ' +
54
+ 'Pass values for the flow\'s declared parameters in "params". Browse available flows with glob/read_file on .mocode/flows/. ' +
55
+ 'The user is asked to approve each run; flows that type sensitive text always need per-run approval.',
56
+ risk: 'dangerous',
57
+ parameters: {
58
+ type: 'object',
59
+ properties: {
60
+ name: { type: 'string', description: 'Flow name (file name without .json).' },
61
+ params: {
62
+ type: 'object',
63
+ description: 'Values for the flow parameters, e.g. {"text_3": "hello"}. Unknown or missing required parameters are rejected.',
64
+ additionalProperties: { type: ['string', 'number', 'boolean'] },
65
+ },
66
+ window_title_regex: {
67
+ type: 'string',
68
+ description: 'Optional case-insensitive regex that overrides the recorded window title match for every window/element step. ' +
69
+ 'Use it when several windows of the same app are open (e.g. two Notepad windows) so the target is unambiguous.',
70
+ },
71
+ },
72
+ required: ['name'],
73
+ additionalProperties: false,
74
+ },
75
+ async execute(args, ctx) {
76
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
77
+ const loaded = loadFlow(name);
78
+ if (!loaded.ok) {
79
+ return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output: loaded.error };
80
+ }
81
+ const params = args.params && typeof args.params === 'object' && !Array.isArray(args.params)
82
+ ? args.params
83
+ : undefined;
84
+ let windowTitleRegex;
85
+ if (args.window_title_regex !== undefined) {
86
+ const raw = args.window_title_regex;
87
+ if (typeof raw !== 'string' || raw.trim() === '') {
88
+ return {
89
+ status: 'error',
90
+ code: 'INVALID_ARGUMENTS',
91
+ retryable: false,
92
+ output: 'window_title_regex must be a non-empty regex string',
93
+ };
94
+ }
95
+ try {
96
+ new RegExp(raw, 'i');
97
+ }
98
+ catch {
99
+ return {
100
+ status: 'error',
101
+ code: 'INVALID_ARGUMENTS',
102
+ retryable: false,
103
+ output: `window_title_regex is not a valid regular expression: ${raw}`,
104
+ };
105
+ }
106
+ windowTitleRegex = raw;
107
+ }
108
+ const result = await runFlowWithComputer(loaded.flow, params, ctx?.signal, { windowTitleRegex });
109
+ const output = formatFlowRunResult(loaded.flow, result);
110
+ const modelAttachments = result.attachments;
111
+ switch (result.status) {
112
+ case 'completed':
113
+ return { status: 'success', code: 'OK', retryable: false, output, modelAttachments };
114
+ case 'aborted':
115
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output };
116
+ case 'rejected':
117
+ return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output };
118
+ default:
119
+ // handoff / failed:不自动重试(步骤级重试已在 runner 内做过),由模型接手或报告。
120
+ return { status: 'error', code: 'POSTCONDITION_FAILED', retryable: false, output, modelAttachments };
121
+ }
122
+ },
123
+ };
@@ -149,6 +149,7 @@ export const PLAN_DISABLED_TOOLS = new Set([
149
149
  'sub-agent',
150
150
  'run_skill', // fork 子 agent 执行面;plan 模式不应派生子工作流
151
151
  'computer', // 桌面操控:plan 只读模式无论开关状态都永远屏蔽
152
+ 'run_flow', // 回放录制的桌面 flow:同 computer,plan 模式永远屏蔽
152
153
  ]);
153
154
  /**
154
155
  * Legacy plan schema 的动态清理:Memory Index 关闭时从旧 profile 屏蔽集删除不存在于该
@@ -210,7 +210,7 @@ Routing rules:
210
210
  - background-exec is for any process that must keep running after the tool call returns: dev servers, inference/model services, watchers, log tails, or a command you will poll later. Foreground run_command blocks and is killed at its timeout, so it cannot host them.
211
211
  - Web UI DOM/console/network/page sessions need browser-debug; a local web server alone only needs background-exec.
212
212
  - Merely observing system dialogs or non-browser windows needs desktop-observe.
213
- - computer-control requires explicit real GUI clicking, typing, scrolling, or desktop application operation; never infer it from the word "browser" alone.
213
+ - computer-control requires explicit real GUI clicking, typing, scrolling, or desktop application operation, including replaying a recorded desktop flow (run_flow); never infer it from the word "browser" alone.
214
214
  - memory-write requires explicit intent to remember, update, forget, or link cross-session knowledge.
215
215
  - orchestration is for exploration-heavy or parallelizable work: open-ended codebase investigation, multi-file research or audits, independent sub-tasks that can run in parallel, or a fork skill. Prefer it whenever the task needs substantial exploration whose tool output would flood the main context; a single trivial lookup does not need it.
216
216
  - For short continuations such as "continue", "do it", or "fix that", inherit previous groups unless the user clearly starts a new task.
@@ -72,6 +72,62 @@ function formatErrors(errors, schema) {
72
72
  }
73
73
  return body + hint;
74
74
  }
75
+ /** 弱模型/中转网关对嵌套结构不稳定的序列化产物:array/object 被整体 JSON.stringify
76
+ * 成字符串塞进外层 JSON(如 plan_update {"steps":"[{...}]"},真实会话 2026-10-09)。
77
+ * AJV coerceTypes 只覆盖标量(number/integer/boolean),不做 string→array/object 转换,
78
+ * 模型会反复撞 "/steps must be array" 乒乓失败。
79
+ *
80
+ * 按 schema 声明反向还原:仅在字段声明为 array/object 且实际值是形似 JSON 的字符串
81
+ * ([ 或 { 开头)且能成功 parse 出对应类型时替换;parse 失败/类型不符保持原值,
82
+ * 照旧由 schema 校验报原始错误——不吞错误、不造数据。递归一层嵌套对象
83
+ * (如 ask_human options、plan_update steps 的 items),数组元素本身不再深递归
84
+ * (元素级偏差交给各工具自己的 normalizeArguments 别名归一)。
85
+ */
86
+ function coerceStringifiedContainers(schema, args) {
87
+ const properties = schema?.properties;
88
+ if (!properties)
89
+ return;
90
+ for (const [key, declared] of Object.entries(properties)) {
91
+ const raw = args[key];
92
+ if (typeof raw !== 'string')
93
+ continue;
94
+ const trimmed = raw.trim();
95
+ const type = declared.type;
96
+ const looksLike = type === 'array' ? trimmed.startsWith('[') : type === 'object' ? trimmed.startsWith('{') : false;
97
+ if (!looksLike)
98
+ continue;
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(trimmed);
102
+ }
103
+ catch {
104
+ continue;
105
+ }
106
+ const typeMatches = type === 'array'
107
+ ? Array.isArray(parsed)
108
+ : typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
109
+ if (typeMatches)
110
+ args[key] = parsed;
111
+ }
112
+ // 递归一层:已还原/本来就是 object 的字段,其属性级偏差同样还原(如 steps[i] 里的数组字段)。
113
+ for (const [key, declared] of Object.entries(properties)) {
114
+ const child = args[key];
115
+ if (typeof child !== 'object' || child === null)
116
+ continue;
117
+ const childSchema = declared.properties;
118
+ if (childSchema && !Array.isArray(child)) {
119
+ coerceStringifiedContainers(declared, child);
120
+ }
121
+ const itemSchema = declared.items;
122
+ if (Array.isArray(child) && itemSchema && typeof itemSchema === 'object') {
123
+ for (const element of child) {
124
+ if (typeof element === 'object' && element !== null && !Array.isArray(element)) {
125
+ coerceStringifiedContainers(itemSchema, element);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
75
131
  /** Validate after tool-local normalization; AJV coerces scalar strings to declared types. */
76
132
  export function validateToolArguments(tool, args) {
77
133
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
@@ -81,6 +137,7 @@ export function validateToolArguments(tool, args) {
81
137
  message: '参数根节点必须是 JSON object',
82
138
  };
83
139
  }
140
+ coerceStringifiedContainers(tool.parameters, args);
84
141
  try {
85
142
  tool.normalizeArguments?.(args);
86
143
  }
@@ -433,13 +433,16 @@ export async function promptComposer(opts = {}) {
433
433
  // 底边框
434
434
  buf += CUP(boxBottom, left);
435
435
  buf += `${ui.accent}╰${'─'.repeat(innerW)}╯${ui.reset}`;
436
- // 光标(滚轮翻看把光标滚出可视区时不画,防 CUP 落到边框/提示行上)
436
+ // 光标(滚轮翻看把光标滚出可视区时不画,防 CUP 落到边框/提示行上)。
437
+ // 同步上报给 layout:运行态底栏照常刷新,每次写完据此把光标还给弹窗(而非底栏输入框)。
438
+ let caret = null;
437
439
  const cr = rows[cIdx];
438
440
  if (cr) {
439
441
  const rowIdx = cIdx - scrollRow;
440
442
  if (rowIdx >= 0 && rowIdx < textRows) {
441
443
  const vis = displayWidth([...cr.text].slice(0, cur.col - cr.start).join(''));
442
- buf += CUP(textTop + rowIdx, left + 1 + vis) + SHOW;
444
+ caret = { row: textTop + rowIdx, col: left + 1 + vis };
445
+ buf += CUP(caret.row, caret.col) + SHOW;
443
446
  }
444
447
  else {
445
448
  buf += SHOW;
@@ -448,6 +451,7 @@ export async function promptComposer(opts = {}) {
448
451
  else {
449
452
  buf += SHOW;
450
453
  }
454
+ layout.setOverlayCaret(caret);
451
455
  try {
452
456
  layout.writeDirect(buf);
453
457
  }
@@ -185,7 +185,8 @@ export function contentWrite(s) {
185
185
  // 物理写(回尾 offset=0):cup 写起点 + s + (pending 时补 \n 提交换行)+ (运行态 cup 回输入框)。
186
186
  // 打字中不再暂停物理写——单次 write 结尾 cup 回 runningCaretPos(输入框),IME 锚定不动(跟踪每次 write
187
187
  // 最终位置);旧设计 isStreamingPaused 暂停写致流式卡顿,现单次写归位已无需暂停。
188
- if (state.scrollOffset === 0) {
188
+ // 覆盖层(composer)打开中:只喂缓冲不物理写,关闭后 repaintViewport 整幅还原。
189
+ if (state.scrollOffset === 0 && !state.overlayActive) {
189
190
  let out = cup(startRow, startCol) + s;
190
191
  if (pendingWrap)
191
192
  out += '\n'; // 滚动区域底行触发 DECSTBM 上滚、中段 LF 下移到 (下一行,1)
@@ -229,7 +230,7 @@ function commitMd() {
229
230
  // 应让 buffer 顶部始终是「最新的一份 banner」,而不是堆 5 份。
230
231
  // rewriteBanner 在 banner 已建好时原地等长替换顶部 bannerH 行,viewport 自动从 rows[] 头读新版。
231
232
  //
232
- // bannerH = bannerLines(banner()) 的行数(目前 5:4 行 logo+info + 1 空行分隔)。如未来
233
+ // bannerH = bannerLines(banner()) 的行数(目前 7:6 行 logo+info + 1 空行分隔)。如未来
233
234
  // bannerString 变化,调用方需重调 writeBanner 重设 bannerH。
234
235
  /**
235
236
  * 在 content 缓冲顶部建一份 banner;**首次**调用建好(bannerH = lines.length),**重复**调用
@@ -318,7 +319,7 @@ function renderMdSegment() {
318
319
  }
319
320
  }
320
321
  state.mdLastRenderAt = Date.now();
321
- if (state.scrollOffset === 0) {
322
+ if (state.scrollOffset === 0 && !state.overlayActive) {
322
323
  repaintViewport(); // 单次 write 结尾 cup 回 runningCaretPos(运行态),IME 锚输入框;打字中不再暂停
323
324
  if (state.mode === 'running') {
324
325
  const p = runningCaretPos();
@@ -503,15 +504,79 @@ export function renderWelcomeRows(lines, cols) {
503
504
  return ' '.repeat(Math.floor((cols - w) / 2)) + line;
504
505
  });
505
506
  }
506
- /** 写欢迎引导块(逻辑行经 renderWelcomeRows 居中后写入,contentWrite 兜底折行);已在屏上则跳过。 */
507
+ /** 逻辑行折行后占的物理行数(可见宽度 ≥ cols 的行由 contentWrite 折行;空行占 1 行)。 */
508
+ function welcomePhysicalRows(lines, cols) {
509
+ let n = 0;
510
+ for (const line of lines)
511
+ n += Math.max(1, Math.ceil(ansiDisplayWidth(line) / Math.max(1, cols)));
512
+ return n;
513
+ }
514
+ /**
515
+ * 让欢迎块「banner + 块」放得进内容区,否则 viewport 取缓冲尾窗会把顶部 logo 挤出屏外
516
+ * (矮窗口 / 折行多时 logo 不再置顶)。放不下时按段(空行分隔)舍弃:先从末段往前丢,
517
+ * 首段(原生终端提示)优先级最低、其后丢,「快速上手」段最后丢;连一段都放不下则返回 [](不写块)。
518
+ * 预留 1 行余量:块末换行不能把续写位推出内容区底而触发滚动。
519
+ */
520
+ export function fitWelcomeLines(lines, cols, contentBottom, bannerH) {
521
+ const budget = contentBottom - bannerH - 1;
522
+ if (welcomePhysicalRows(lines, cols) <= budget)
523
+ return [...lines];
524
+ // 按空行切段(段内不含空行),重组时用 '' 分隔并首尾各补一个 '',与 welcomeLines 的版式一致
525
+ const groups = [];
526
+ let cur = [];
527
+ for (const line of lines) {
528
+ if (line === '') {
529
+ if (cur.length > 0)
530
+ groups.push(cur);
531
+ cur = [];
532
+ }
533
+ else {
534
+ cur.push(line);
535
+ }
536
+ }
537
+ if (cur.length > 0)
538
+ groups.push(cur);
539
+ // 丢弃顺序:末段 → … → 第 3 段,然后首段,最后第 2 段
540
+ const dropOrder = [];
541
+ for (let i = groups.length - 1; i >= 2; i--)
542
+ dropOrder.push(i);
543
+ if (groups.length >= 1)
544
+ dropOrder.push(0);
545
+ if (groups.length >= 2)
546
+ dropOrder.push(1);
547
+ const alive = new Set(groups.map((_, i) => i));
548
+ const build = () => {
549
+ const out = [''];
550
+ for (let i = 0; i < groups.length; i++) {
551
+ if (!alive.has(i))
552
+ continue;
553
+ out.push(...groups[i], '');
554
+ }
555
+ return out;
556
+ };
557
+ for (const idx of dropOrder) {
558
+ alive.delete(idx);
559
+ if (alive.size === 0)
560
+ return [];
561
+ const built = build();
562
+ if (welcomePhysicalRows(built, cols) <= budget)
563
+ return built;
564
+ }
565
+ return [];
566
+ }
567
+ /** 写欢迎引导块(逻辑行经 fit 裁剪 + renderWelcomeRows 居中后写入,contentWrite 兜底折行);已在屏上则跳过。 */
507
568
  export function writeWelcomeBlock(lines) {
508
569
  if (!state.active || !ui.isTTY || lines.length === 0)
509
570
  return;
510
571
  if (state.welcomeRows > 0)
511
572
  return; // 已在屏上,不重复写
573
+ const g = getGeo();
574
+ const fitted = fitWelcomeLines(lines, g.cols, g.contentBottom, state.bannerH);
575
+ if (fitted.length === 0)
576
+ return; // 窗口太矮:宁可不画,也别把顶部 logo 挤出屏
512
577
  state.welcomeSource = [...lines];
513
578
  state.welcomeStart = content.committedRows();
514
- contentWrite(renderWelcomeRows(lines, getGeo().cols).join('\n') + '\n');
579
+ contentWrite(renderWelcomeRows(fitted, g.cols).join('\n') + '\n');
515
580
  state.welcomeRows = content.committedRows() - state.welcomeStart;
516
581
  }
517
582
  /**
@@ -547,7 +612,10 @@ function reflowWelcomeBlockForResize(cols) {
547
612
  const g = getGeo();
548
613
  state.contentRow = Math.min(start + 1, g.contentBottom);
549
614
  state.contentCol = 1;
550
- contentWrite(renderWelcomeRows(source, cols).join('\n') + '\n');
615
+ // 与 writeWelcomeBlock 同口径先 fit:窗口变矮后按新高度裁剪,避免把顶部 logo 挤出屏。
616
+ const fitted = fitWelcomeLines(source, cols, g.contentBottom, state.bannerH);
617
+ if (fitted.length > 0)
618
+ contentWrite(renderWelcomeRows(fitted, cols).join('\n') + '\n');
551
619
  state.welcomeRows = content.committedRows() - start;
552
620
  const newLines = [];
553
621
  for (let i = start; i < start + state.welcomeRows; i++)
@@ -103,8 +103,11 @@ export function setUserActive() {
103
103
  if (state.active && state.mode === 'running' && state.scrollOffset === 0) {
104
104
  repaintViewport(); // 重画内容区(显示活跃期间缓冲的流式内容)
105
105
  drawStatusBar(); // 刷状态行(活跃期间冻住,现恢复)
106
- const p = runningCaretPos();
107
- stdout.write(cup(p.row, p.col));
106
+ // 覆盖层(composer)打开中:光标归属弹窗(drawStatusBar 已归位到 overlayCaret),不能拽回底栏输入框
107
+ if (!state.overlayActive) {
108
+ const p = runningCaretPos();
109
+ stdout.write(cup(p.row, p.col));
110
+ }
108
111
  }
109
112
  }, USER_ACTIVE_PAUSE_MS);
110
113
  state.flushTimer.unref();
@@ -141,6 +144,19 @@ export function setCursorChangeHandler(fn) {
141
144
  export function setOverlayMouseHandler(fn) {
142
145
  state.overlayMouseHandler = fn;
143
146
  }
147
+ /**
148
+ * 标记全屏覆盖层(运行态 composer)开/关。开启期间内容区 / 状态行 / spinner 帧只喂缓冲不物理写,
149
+ * 防流式输出与 80ms 心跳覆盖弹窗。关闭后由调用方 repaintViewport + drawStatusBar 整幅还原。
150
+ */
151
+ export function setOverlayActive(v) {
152
+ state.overlayActive = v;
153
+ if (!v)
154
+ state.overlayCaret = null;
155
+ }
156
+ /** 覆盖层(composer)每次 paint 上报自己的光标位;overlay 期间底栏刷新后光标归还此处。null = 不归位。 */
157
+ export function setOverlayCaret(pos) {
158
+ state.overlayCaret = pos;
159
+ }
144
160
  /** picker / 介入面板期间禁用鼠标选区与拖拽(避免 viewport 重画覆盖菜单);滚轮仍可用。面板退出后恢复。 */
145
161
  export function setMouseEnabled(v) {
146
162
  state.mouseEnabled = v;
@@ -175,10 +191,13 @@ export function setMouseEnabled(v) {
175
191
  function setInputCursorFromClick(screenRow, screenCol) {
176
192
  if (!state.active || !state.base || !state.lastView)
177
193
  return;
178
- // running 态:先收归输入态(enterInputMode 会 paintInput 整帧重画;之后我们再按新光标重画一次覆盖即可)
179
- if (state.mode !== 'input') {
180
- enterInputMode(state.statusText);
181
- }
194
+ // 调用方守卫保证:仅 INPUT 态或介入面板(cursorChangeHandler 已注册)到达此处。
195
+ // 历史行为是 running 态先 enterInputMode 收归输入态——这会把 RUNNING 击穿成 INPUT:
196
+ // 走时/实时 token chip 的显示门(state.mode==='running')同时熄灭,而 spinner 自身的
197
+ // 80ms 心跳不知道 mode 变了,drawStatusBar 走 INPUT 分支把真光标 cup 到内容区续写位,
198
+ // 产生「点击后 agent 输出区底部闪光标 + 走时消失」的复合 bug。运行态点击输入框已改为
199
+ // handleMouseEvent 直接 no-op(键盘 ←/→/Home/End 仍可定位 typeahead 光标);
200
+ // 介入面板期间不切 mode(面板退出后 startTurnTimerIfRunning 才能恢复走时心跳)。
182
201
  const pos = inputScreenToInputPos(screenRow, screenCol);
183
202
  if (!pos) {
184
203
  paintInput(state.lastView);
@@ -219,9 +238,14 @@ function handleMouseEvent(e) {
219
238
  scrollWheel(e.dir);
220
239
  return;
221
240
  }
222
- // 输入行左键点击:即使 mouseEnabled=false(如 intervention 面板期间)也允许定位光标,
223
- // 提升用户体验(点哪插哪)。拖拽/右键/内容区点击仍受 mouseEnabled 限制。
241
+ // 输入行左键点击:INPUT 态(含 intervention 面板,其 cursorChangeHandler 已注册)允许定位光标,
242
+ // 提升用户体验(点哪插哪)。RUNNING 态直接 no-op——光标定位是 INPUT 态交互,运行态
243
+ // typeahead 由键盘体系管理(←/→/Home/End,running-input.ts),不应触发模式切换(见
244
+ // setInputCursorFromClick 注释)。mouseEnabled=false 期间(面板)仍走定位分支。
245
+ // 拖拽/右键/内容区点击仍受 mouseEnabled 限制。
224
246
  if (e.button === 0 && e.type === 'press' && isInputRow(e.row)) {
247
+ if (state.mode === 'running' && !state.cursorChangeHandler)
248
+ return; // 运行态无文本所有者:点击不改光标
225
249
  setInputCursorFromClick(e.row, e.col);
226
250
  const pos = inputScreenToInputPos(e.row, e.col);
227
251
  if (pos) {
@@ -126,3 +126,7 @@ export function contentMode() {
126
126
  export function isStreamingPaused() {
127
127
  return state.mode === 'running' && Date.now() < state.userActiveUntil;
128
128
  }
129
+ /** 覆盖层(composer)是否打开:是则各物理写路径跳过写屏(缓冲照常更新),关闭后由调用方整幅重画。 */
130
+ export function isOverlayActive() {
131
+ return state.overlayActive;
132
+ }
@@ -41,8 +41,8 @@ export function screenRowToAbsLine(row) {
41
41
  * 直接跳过刷新(冻结视口),而非在这里猜屏幕映射。
42
42
  */
43
43
  export function repaintContentLine(absIdx, line) {
44
- if (!state.active)
45
- return;
44
+ if (!state.active || state.overlayActive)
45
+ return; // 覆盖层打开中不写屏(缓冲已由调用方更新)
46
46
  const g = getGeo();
47
47
  const row = absIdx - viewportAbsStart() + 1;
48
48
  if (row < 1 || row > g.contentBottom)
@@ -73,7 +73,9 @@ export function repaintContentLine(absIdx, line) {
73
73
  * 提示「↑ 这是上方滚走的内容」,不与内容区行内 SGR 冲突。
74
74
  */
75
75
  export function repaintViewport() {
76
- if (!state.active)
76
+ // 覆盖层(composer)打开中不写屏:setUserActive 的 flush 定时器等路径会调到这里,整幅重画会盖掉弹窗;
77
+ // 关闭方先 setOverlayActive(false) 再 repaintViewport 还原。
78
+ if (!state.active || state.overlayActive)
77
79
  return;
78
80
  const g = getGeo();
79
81
  const h = g.contentBottom;
@@ -94,4 +94,10 @@ export const state = {
94
94
  * 返回 true = 已消费(不再走 layout 默认处理);弹窗自己消费全部事件,
95
95
  * 防止背景选区/翻页触发 repaintViewport 重画覆盖弹窗。传 null 注销。 */
96
96
  overlayMouseHandler: null,
97
+ /** 全屏覆盖层(composer 输入面板)打开中:内容区 / 状态行 / spinner 帧只喂缓冲不物理写,
98
+ * 防运行态流式输出与心跳覆盖弹窗、抢走弹窗光标。关闭方负责 repaintViewport 整幅还原。 */
99
+ overlayActive: false,
100
+ /** 覆盖层自报的真光标位(1-based 屏坐标;null = 未报/光标不在可视区)。overlay 期间 drawStatusBar
101
+ * 照常刷底栏,但末尾 cup 回这里而非 runningCaretPos,防 80ms 心跳把弹窗光标拽到底栏输入框。 */
102
+ overlayCaret: null,
97
103
  };
@@ -200,6 +200,8 @@ export function composePlanLines(status, cols) {
200
200
  export function drawStatusBar(status) {
201
201
  if (!state.active || !state.base)
202
202
  return;
203
+ // 覆盖层(composer)打开中照常刷底栏:弹窗只占内容区(2..contentBottom-1),底栏行在其外不冲突;
204
+ // 仅末尾光标归位改为弹窗自报位(见下),防 80ms 心跳把弹窗光标拽到底栏输入框。
203
205
  const s = status ?? { ...state.base, status: state.statusText, spinnerFrame: state.spinnerFrame };
204
206
  const g = getGeo();
205
207
  const planLines = composePlanLines(s, g.cols);
@@ -228,7 +230,12 @@ export function drawStatusBar(status) {
228
230
  cup(modelRow, 1) +
229
231
  esc.clearLine +
230
232
  composeModelLine(s, g.cols);
231
- if (state.mode === 'running') {
233
+ if (state.overlayActive) {
234
+ // 覆盖层打开中:光标还给弹窗自报位(未报/光标滚出可视区时不归位,由弹窗下次 paint 自行定位)
235
+ if (state.overlayCaret)
236
+ out += cup(state.overlayCaret.row, state.overlayCaret.col);
237
+ }
238
+ else if (state.mode === 'running') {
232
239
  // 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
233
240
  const p = runningCaretPos();
234
241
  out += cup(p.row, p.col);
@@ -283,6 +290,8 @@ export function startTurnTimerIfRunning() {
283
290
  * 打字暂停态(isStreamingPaused)spinner 不画帧——恢复 557e678 前的 spinner 隐形行为(只关 spinner,不动 contentWrite)。
284
291
  */
285
292
  export function paintLiveAtCursor(text) {
293
+ if (state.overlayActive)
294
+ return; // 覆盖层(composer)打开中:不画帧(否则 80ms 心跳覆盖弹窗、抢光标)
286
295
  if (!state.active || !ui.isTTY || state.scrollOffset !== 0 || isStreamingPaused()) {
287
296
  // 滚动态 / 暂停态:不画新帧。但若有旧帧残留(frameRow),必须清掉——
288
297
  // 否则旧 spinner 帧停在历史视图某行,用户看到「思考中」卡在消息堆里(根因)。
@@ -339,6 +348,12 @@ export function clearLiveAtCursor() {
339
348
  return;
340
349
  if (!state.frameRow)
341
350
  return; // 没画过就不清(避免误清当前续写位内容)
351
+ if (state.overlayActive) {
352
+ // 覆盖层打开中:帧不在缓冲里,关闭后 repaintViewport 整幅重画自然抹掉;此处不写屏,只清记录
353
+ state.frameRow = 0;
354
+ state.frameCol = 0;
355
+ return;
356
+ }
342
357
  // 滚动态也清:旧帧不该残留。清"画过的行"(frameRow),回尾后该行由 repaintViewport 重画历史内容。
343
358
  // resize 后 contentBottom 可能缩小:frameRow 钳到可视区,避免 cup 到屏外行清错位置。
344
359
  const g = getGeo();
package/dist/ui/render.js CHANGED
@@ -326,12 +326,17 @@ export function wrapByDisplayWidth(text, width) {
326
326
  return out;
327
327
  }
328
328
  const MARGIN = ' '; // 盒外左缩进
329
- // ── MoCode 块字符 logo(4 行,neofetch 风)──
330
- const LOGO_W = 35; // logo 区显示宽度
329
+ // ── MoCode 块字符 logo(6 行,neofetch 风)──
330
+ const LOGO_W = 35; // logo 区显示宽度(字母 30 + 5 格留白)
331
331
  const LOGO_GAP = 0; // logo 与信息区之间的间隔
332
- // 块字符画 4 行:M / o / C / o / d / e,块字符上半/下半合并两个 ASCII 行
332
+ // 块字符画:M / o / C / o / d / e,块字符上半/下半合并两个 ASCII 行(后 4 行);
333
+ // 第一个 o(第 6-9 列)头上坐着早期版本(6be25e8)的作者小人,眼睛 ◑(右半实心)朝右看。
334
+ // 屁股 ╰ 落在 o 顶边上半块 █▀▀█(贴格子顶,无半格空隙);腿 ──── 往右伸,脚 OO 在第 11-14 列。
335
+ // 腿与 d 的升部 ▄(第 24 列)同一行
333
336
  const LOGO_LINES = [
334
- ' ▄ ',
337
+ ' [◕ᴗ◕]',
338
+ ' ●| |●',
339
+ ' OO ▄ ',
335
340
  '█▀█▀█ █▀▀█ ▄▄▄▄ ▄▄▄▄ ▄▄▄█ ▄▄▄▄',
336
341
  '█ █ █ █ █ █ ▀ █ █ █ █ █▄▄█',
337
342
  '▀ ▀ ▀ ▀▀▀▀ █▄▄█ █▄▄█ █▄▄█ █▄▄▄',
@@ -353,17 +358,20 @@ function memoryValue(on) {
353
358
  return on ? `${ui.accent}${memoryLabel(on)}${ui.reset}` : `${ui.dim}${memoryLabel(on)}${ui.reset}`;
354
359
  }
355
360
  /** 横幅纯文本(带 ANSI 颜色,不写出)——供 TUI 经 contentWrite 写入内容区以跟踪续写位。
356
- * 布局:大字 logo(4 行,块字符)左对齐,右侧并排放标题/信息(neofetch 风)。 */
361
+ * 布局:大字 logo(6 行:小人 2 行 + 脚/升部 1 行 + 字母 3 行)左对齐,
362
+ * 标题/信息并排放在后 4 行右侧(neofetch 风),小人头/身两行右侧留空。 */
357
363
  export function bannerString(info) {
358
364
  const title = `${ui.bold}${ui.accent}● MoCode${ui.reset} ${ui.dim}v${VERSION}${ui.reset}`;
359
365
  const labels = [t('banner.model'), t('banner.directory'), t('banner.memory')];
360
366
  // 标签列按当前语言最长文本动态定宽,并至少留两个空格;中文保持原 6 列,英文扩至 11 列。
361
367
  const labelWidth = Math.max(...labels.map(displayWidth)) + 2;
362
368
  const rows = [
363
- logoLine(0) + title,
364
- logoLine(1) + labelContent(labels[0], info.model, labelWidth),
365
- logoLine(2) + labelContent(labels[1], truncateDisplay(info.cwd, 48), labelWidth),
366
- logoLine(3) + labelContent(labels[2], memoryValue(info.memoryEnabled), labelWidth),
369
+ logoLine(0),
370
+ logoLine(1),
371
+ logoLine(2) + title,
372
+ logoLine(3) + labelContent(labels[0], info.model, labelWidth),
373
+ logoLine(4) + labelContent(labels[1], truncateDisplay(info.cwd, 48), labelWidth),
374
+ logoLine(5) + labelContent(labels[2], memoryValue(info.memoryEnabled), labelWidth),
367
375
  ];
368
376
  return rows.map((r) => MARGIN + r).join('\n') + '\n\n';
369
377
  }
@@ -383,8 +391,8 @@ export function printBanner(info) {
383
391
  */
384
392
  export function bannerLines(info) {
385
393
  const raw = bannerString(info);
386
- // bannerString 形如 "行1\n行2\n行3\n行4\n",split('\n') 长度 = 5(末尾 \n 后空串)
387
- // 行集合真实包含 4 行(4 行 logo+info);filter(Boolean?) 不可 — 空行也要算
394
+ // bannerString 形如 "行1\n…\n行6\n\n",末尾 \n 后切出一个尾空串
395
+ // 行集合 = 6 行 logo+info + 1 空行分隔;filter(Boolean?) 不可 — 空行也要算
388
396
  // 仅过滤最后那个 \n 切出的尾空
389
397
  const split = raw.split('\n');
390
398
  const lines = split.length > 0 && split[split.length - 1] === '' ? split.slice(0, -1) : split;