mocode-ai 1.1.4 → 1.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.
package/README.md CHANGED
@@ -10,6 +10,19 @@ MoCode explores your code, reads/writes/edits files, runs shell commands, and se
10
10
 
11
11
  ## Architecture
12
12
 
13
+ ## Engineering discipline
14
+
15
+ MoCode encodes "how to take coding seriously" into the agent's own behavior, not just into the prompt:
16
+
17
+ - **Plan → Build → Verify → Fix four-phase discipline** — Injected fresh each turn into `buildBasePrompt`, with per-model-family light adaptation. The agent must restate the task, plan, and acceptance signal before touching anything; changes must pass an automatic validation gate; on failure the agent enters a Fix phase and feeds real command output back as a fresh observation. Evidence: `src/agent/work-discipline.ts` + `evals/work-discipline.ts` (6 assertions). Basis: [`docs/coding-harness-quality-roadmap.md` §4.1 PROMPT-01](docs/coding-harness-quality-roadmap.md).
18
+ - **Pre-Completion Checklist middleware** — `finish`/`stop` is blocked when `mutation > 0 && no tool call && validation !== 'passed'`. Simple read-only tasks deliberately bypass it to avoid noise. Evidence: `src/agent/middleware/checklist.ts` + `evals/checklist.ts` (6 assertions).
19
+ - **Reflective retry + thrash throttling** — Errors are classified into 6 categories (`retry-classifier`); the same tool with the same args ≥3 times appends a hint to switch strategy; failed traces stay in context but receive a targeted reflection prompt instead of a blind retry. Evidence: `src/tools/retry.ts` + `src/agent/retry-classifier.ts` + `evals/retry-classifier.ts` (9 assertions).
20
+ - **`ask_human` as a deliberate de-escalation** — No blind guessing. On whitelisted scenarios (sandbox deviation, ambiguous params, conflicting user instructions) the agent prefers "disclose rather than guess" and will explicitly call `ask_human` to pop a panel for your decision (with a call budget). Evidence: ASK_WHITELIST_SECTION in `src/agent/work-discipline.ts` + `evals/ask-budget.ts` (6 assertions).
21
+ - **Verification cascade V0 → V3 with content fingerprint cache** — Changes pass through file post-conditions → scoped tsc/eslint → targeted unit tests → affected npm scripts, in that order; the first actionable failure stops the cascade; a SHA-256 content cache skips repeated work on unchanged files. Evidence: `src/validators/` plus the VER chapters in the main roadmap.
22
+ - **Five-zone context controls + token self-calibration** — Five independent dials (`autoCompact` / `contextOptimize` / `contextRelprune` / `contextLifecycle` / `contextBudget`), each with its own `MOCODE_*=false` kill switch; even with all five off, observations still age through the lifecycle. Token estimation uses EWMA to self-calibrate against real provider usage rather than trusting the estimator. Evidence: the five modules under `src/context/` plus the `MOCODE_*` switches in `src/config/index.ts`.
23
+
24
+ ## Architecture
25
+
13
26
  MoCode is organized as a layered runtime: the terminal experience drives an autonomous core, the core reaches capabilities through a guarded execution plane, and a persistent intelligence layer keeps long-running work coherent.
14
27
 
15
28
  <p align="center"><img src="./assets/architecture/system-overview.svg" alt="MoCode layered system architecture" width="100%"></p>
package/README.zh-CN.md CHANGED
@@ -64,6 +64,19 @@ MoCode 是一个分层的自治运行时:终端交互层驱动 Agent 内核,
64
64
 
65
65
  ## 为什么用 mocode
66
66
 
67
+ ## 工程化纪律
68
+
69
+ mocode 把"如何认真写代码"这件事也写进了 agent 自身的行为准则,而不是只靠 prompt 教:
70
+
71
+ - **Plan → Build → Verify → Fix 四阶段纪律** — 每轮 prompt 现拼现读注入 `buildBasePrompt`,并按模型家族做轻量适配;agent 必须先复述任务、规划与验收信号,再动手,改动必经自动验证门,失败时进入修复阶段并把真实命令输出当新观察反馈。证据:`src/agent/work-discipline.ts` + `evals/work-discipline.ts`(6 块断言)。依据:[`docs/coding-harness-quality-roadmap.md` §4.1 PROMPT-01](docs/coding-harness-quality-roadmap.md)。
72
+ - **Pre-Completion Checklist 硬关卡** — 在 `mutation > 0 && no tool call && validation !== 'passed'` 三个条件同时成立前,`finish`/`stop` 不会被放行;简单无改动的任务刻意不触发,避免噪音。证据:`src/agent/middleware/checklist.ts` + `evals/checklist.ts`(6 块断言)。
73
+ - **反思式重试 + thrash 节流** — 错误按 6 类分类(`retry-classifier`),同一工具同参数 ≥3 次追加 hint 提醒换策略;失败 trace 留在上下文中但有针对性反思 prompt 注入,而不是盲目重试。证据:`src/tools/retry.ts` + `src/agent/retry-classifier.ts` + `evals/retry-classifier.ts`(9 块断言)。
74
+ - **ask_human 卡点降级** — 不盲猜:遇到 sandbox 偏差、参数二义、用户指令冲突等白名单场景时,agent 倾向"披露而不是瞎猜",必要时显式调用 `ask_human` 弹面板让你拍板(带调用预算)。证据:`src/agent/work-discipline.ts` 的 ASK_WHITELIST_SECTION + `evals/ask-budget.ts`(6 块断言)。
75
+ - **验证瀑布 V0 → V3 + 内容指纹缓存** — 改动先走文件后置条件 → 受限 tsc/eslint → 定向单测 → 受影响 npm 脚本,首个可操作失败立刻停;SHA-256 文件指纹缓存保证未改动文件不重复劳动。证据:`src/validators/` + 主路线图 VER 章节。
76
+ - **五区上下文控制 + token 自校准** — 五个独立开关各自把控一档(`autoCompact` / `contextOptimize` / `contextRelprune` / `contextLifecycle` / `contextBudget`),即使全关观察结果也按生命周期老化;token 估算走 EWMA 自动校准真实 provider 用量,而不是死信估算函数。证据:`src/context/` 五模块 + `src/config/index.ts` 的 `MOCODE_*` 开关。
77
+
78
+ ## 为什么用 mocode
79
+
67
80
  mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
68
81
 
69
82
  - **自主多步推进** — 一次对话里连续多步:读代码、改代码、跑测试、根据报错再改……agent 自己决定下一步,中途不用你反复催。遇到卡点会调 `ask_human` 弹面板问你(阻塞到回应)。
package/dist/llm/index.js CHANGED
@@ -3,6 +3,13 @@ import { config, isSubAgentEnabled } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
4
  import { getPlanDisabledTools } from '../tools/constants.js';
5
5
  import { ThinkTagFilter } from './think-filter.js';
6
+ // 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
7
+ // console.log 把请求/响应直写 stdout,会污染 TUI 输入框(并泄露 headers/URL)。
8
+ // 仅拦截 'true' 这一开关值——保留 namespace 形式的 DEBUG(如 DEBUG=express:*) 调试能力。
9
+ // 必须在 OpenAI 客户端实例化之前执行,确保任何实例都不再触发 debug 输出。
10
+ if (process.env.DEBUG === 'true') {
11
+ process.env.DEBUG = 'false';
12
+ }
6
13
  /**
7
14
  * LLM 调用重试策略:
8
15
  * 可重试 → 429 (rate limit) / 5xx (server) / APIConnectionError / Node 网络错 (ETIMEDOUT 等)
@@ -342,6 +342,7 @@ const emitter = stdin;
342
342
  // ── 运行态交互(typeahead 输入 + 滚动回看 + Ctrl+C 中断)──
343
343
  // 只在 await runAgent() 期间挂载;/resume /rollback /compact 等走 askLine(cooked readline)的分支不挂(避免抢 stdin)。
344
344
  let runningInput = ''; // 运行中已打字缓冲(单行;agent 结束后预填下一轮 INPUT 态)
345
+ let runningCursor = 0; // 缓冲内光标字符索引(0..len);运行态支持任意位置编辑,与空闲态一致
345
346
  let runningPlaceholder = '';
346
347
  let currentAbort = null;
347
348
  let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
@@ -391,7 +392,8 @@ function onRunningKey(_str, key) {
391
392
  if (key.ctrl && key.name === 'c') {
392
393
  if (runningInput.length > 0) {
393
394
  runningInput = '';
394
- layout.paintRunningInputEcho(runningInput, runningPlaceholder);
395
+ runningCursor = 0;
396
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
395
397
  }
396
398
  else if (currentAbort && !currentAbort.signal.aborted) {
397
399
  appendCurrentSessionRuntimeEvent('abort', { phase: 'requested', source: 'keyboard' });
@@ -400,16 +402,46 @@ function onRunningKey(_str, key) {
400
402
  return;
401
403
  }
402
404
  const s = key.sequence ?? '';
405
+ // 光标移动(单行 typeahead,光标可任意位置,与空闲态一致)
406
+ if (key.name === 'left') {
407
+ runningCursor = Math.max(0, runningCursor - 1);
408
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
409
+ return;
410
+ }
411
+ if (key.name === 'right') {
412
+ runningCursor = Math.min(runningInput.length, runningCursor + 1);
413
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
414
+ return;
415
+ }
416
+ if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
417
+ runningCursor = 0;
418
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
419
+ return;
420
+ }
421
+ if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
422
+ runningCursor = runningInput.length;
423
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
424
+ return;
425
+ }
403
426
  if (key.name === 'backspace') {
404
- if (runningInput.length > 0) {
405
- runningInput = runningInput.slice(0, -1);
406
- layout.paintRunningInputEcho(runningInput, runningPlaceholder);
427
+ if (runningCursor > 0) {
428
+ runningInput = runningInput.slice(0, runningCursor - 1) + runningInput.slice(runningCursor);
429
+ runningCursor--;
430
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
431
+ }
432
+ return;
433
+ }
434
+ if (key.name === 'delete') {
435
+ if (runningCursor < runningInput.length) {
436
+ runningInput = runningInput.slice(0, runningCursor) + runningInput.slice(runningCursor + 1);
437
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
407
438
  }
408
439
  return;
409
440
  }
410
441
  if (key.name === 'escape') {
411
442
  runningInput = '';
412
- layout.paintRunningInputEcho(runningInput, runningPlaceholder);
443
+ runningCursor = 0;
444
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
413
445
  return;
414
446
  }
415
447
  // Enter / Ctrl+J:运行中 no-op(单行 typeahead;agent 结束后预填,用户在 INPUT 态按 Enter 提交)
@@ -418,21 +450,25 @@ function onRunningKey(_str, key) {
418
450
  (key.ctrl && key.name === 'j')) {
419
451
  return;
420
452
  }
421
- // 可打印字符(>= 空格,非 ctrl/meta)→ 追加 + dim 回显
453
+ // 可打印字符(>= 空格,非 ctrl/meta)→ 光标处插入 + dim 回显(与空闲态一致)
422
454
  if (s && s >= ' ' && !key.ctrl && !key.meta) {
423
- runningInput += s;
424
- layout.paintRunningInputEcho(runningInput, runningPlaceholder);
455
+ runningInput = runningInput.slice(0, runningCursor) + s + runningInput.slice(runningCursor);
456
+ runningCursor += s.length;
457
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
425
458
  }
426
459
  }
427
- /** 鼠标右键单击输入框(未拖动)时 layout 读剪贴板后回调:追加到 typeahead 缓冲(简单粘贴,不分长短)。 */
460
+ /** 鼠标右键单击输入框(未拖动)时 layout 读剪贴板后回调:在光标处插入 typeahead 缓冲(单行,换行折为空格)。 */
428
461
  function onRunningMousePaste(text) {
429
- runningInput += text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
430
- layout.paintRunningInputEcho(runningInput, runningPlaceholder);
462
+ const flat = text.replace(/[\r\n]+/g, ' ');
463
+ runningInput = runningInput.slice(0, runningCursor) + flat + runningInput.slice(runningCursor);
464
+ runningCursor += flat.length;
465
+ layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
431
466
  }
432
467
  /** 进入运行态:挂 keypress 监听 + raw mode + 新建 abort 控制器,返回其 signal。在 await runAgent 前、enterRunningMode 后调。 */
433
468
  function startRunningListener(placeholder) {
434
469
  runningPlaceholder = placeholder;
435
470
  runningInput = '';
471
+ runningCursor = 0;
436
472
  emitKeypressEvents(stdin); // 幂等:首轮 prompt 已永久挂解析器,这里防御性再调
437
473
  try {
438
474
  stdin.setRawMode(true);
@@ -760,6 +796,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
760
796
  layout.contentMode();
761
797
  if (history.some((m) => m.role === 'user')) {
762
798
  renderHistory(history);
799
+ // 强制回尾:同 /resume 命令,renderHistory 展开详情会设 scrollOffset>0,需复位避免闪烁。
800
+ layout.resetScroll();
801
+ layout.repaintViewport();
763
802
  }
764
803
  else {
765
804
  layout.writeBanner(bannerLines(banner()));
@@ -907,6 +946,11 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
907
946
  // rollbackFlow 原本漏了这一行,renderHistory 末尾的 batch 摘要行 / assistant 文本
908
947
  // 收口后,续写位未稳到新空行,下一次 echoInput 的 ❯ 气泡会黏在最后一条输出后面。
909
948
  layout.contentWrite('\n');
949
+ // 回滚后强制回尾:确保 scrollOffset=0,避免后续 showLiveBatch 在冻结视口下
950
+ // 调用 repaintViewport 导致工具信息闪烁/滚动消失(rollback 后用户继续输入触发新 agent
951
+ // 运行时,若 scrollOffset 意外 >0,contentWrite 只喂缓冲不物理写,showLiveBatch 的
952
+ // repaintViewport 画出不含新摘要的冻结窗口 → 工具信息"闪现后消失")。
953
+ layout.resetScroll();
910
954
  pendingPrefill = prefillText.split('\n');
911
955
  };
912
956
  /**
@@ -1042,6 +1086,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1042
1086
  renderHistory(history);
1043
1087
  // 末尾 \n\n:与后续用户消息(❯ bubble)之间空一行。
1044
1088
  layout.contentWrite(`${ui.dim}${t('repl.resumed', { id: loaded.id })}${ui.reset}\n\n`);
1089
+ // 强制回尾:renderHistory 展开 mutation 工具详情会经 contentInsertAfter 设置 scrollOffset>0;
1090
+ // 先把"已续接会话"提示写入缓冲,再统一回尾重画,避免切换后视口停留在历史顶部。
1091
+ layout.resetScroll();
1092
+ layout.repaintViewport();
1045
1093
  }
1046
1094
  let hasSubmittedInput = false;
1047
1095
  while (true) {
package/dist/ui/batch.js CHANGED
@@ -194,7 +194,12 @@ export function showLiveBatch(id, layout) {
194
194
  // 首条摘要通过增量 contentWrite 落屏时,markdown→普通内容的边界可能只更新了
195
195
  // buffer/续写位;直到第二个 header 的 contentReplaceLine 或后续正文重绘才完全可见。
196
196
  // 立即按 buffer 原子重画,确保慢工具执行期间摘要前的空行已经显示。
197
- layout.repaintViewport?.();
197
+ // 但滚动回看时(scrollOffset>0) contentWrite 只喂缓冲+冻结视口,此时 repaintViewport
198
+ // 会画出不含新摘要的冻结窗口(新摘要在窗口之下),造成闪烁;跳过重画,
199
+ // 等用户回底(scrollOffset=0)时自然可见。修复 rollback 后工具信息滚动消失问题。
200
+ if (!layout.isScrolled?.()) {
201
+ layout.repaintViewport?.();
202
+ }
198
203
  }
199
204
  else {
200
205
  layout.contentReplaceLine(b.summaryAbsIdx, summary);
@@ -205,7 +205,6 @@ export async function promptIntervention(req) {
205
205
  cursorLine: 0,
206
206
  cursorCol: 0,
207
207
  menu: null,
208
- dim: true,
209
208
  });
210
209
  if (layout.isScrolled())
211
210
  layout.resetScroll();
package/dist/ui/layout.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { stdin, stdout } from 'node:process';
2
+ import { inspect } from 'node:util';
2
3
  import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncateAnsi, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
3
4
  import { ui, applyTerminalBackground, resetTerminalBackground } from './theme.js';
4
5
  import * as content from './content.js';
@@ -9,6 +10,58 @@ import { renderMarkdown } from './markdown.js';
9
10
  import { t } from '../i18n/index.js';
10
11
  // ── 内部状态 ──
11
12
  let active = false;
13
+ // ── 裸 console 防御:第三方库(如 openai SDK)可能用 console.log 直写 stdout,
14
+ // 在 RUNNING 态会落到光标所在的底栏输入框,污染输入。进入 TUI 后把 console.*
15
+ // 劫持到 contentWrite,统一进内容区(运行态下 contentWrite 末尾会把真光标归位输入框),
16
+ // 既不再泄漏到输入框,也不会破坏 TUI 布局。TUI 外(active=false)不劫持,
17
+ // 恢复原始 console,保证 host 子进程 JSON 协议 / 退出日志正常。
18
+ let consoleHookInstalled = false;
19
+ const origConsole = {
20
+ log: console.log,
21
+ error: console.error,
22
+ warn: console.warn,
23
+ info: console.info,
24
+ };
25
+ function routeConsoleToContent(method) {
26
+ const c = console;
27
+ c[method] = (...args) => {
28
+ if (active && ui.isTTY) {
29
+ let s;
30
+ try {
31
+ s = args
32
+ .map((a) => (typeof a === 'string' ? a : inspect(a, { depth: 4 })))
33
+ .join(' ');
34
+ }
35
+ catch {
36
+ s = String(args[0]);
37
+ }
38
+ if (!s.endsWith('\n'))
39
+ s += '\n';
40
+ contentWrite(s);
41
+ }
42
+ else {
43
+ origConsole[method](...args);
44
+ }
45
+ };
46
+ }
47
+ function installConsoleGuard() {
48
+ if (consoleHookInstalled)
49
+ return;
50
+ routeConsoleToContent('log');
51
+ routeConsoleToContent('error');
52
+ routeConsoleToContent('warn');
53
+ routeConsoleToContent('info');
54
+ consoleHookInstalled = true;
55
+ }
56
+ function uninstallConsoleGuard() {
57
+ if (!consoleHookInstalled)
58
+ return;
59
+ console.log = origConsole.log;
60
+ console.error = origConsole.error;
61
+ console.warn = origConsole.warn;
62
+ console.info = origConsole.info;
63
+ consoleHookInstalled = false;
64
+ }
12
65
  let mode = 'input';
13
66
  let footerH = 6; // 1 虚拟空行 + 1 spinner行 + 1 上线 + 输入行数 + 1 下线 + 1 model行(两行式底栏)
14
67
  let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
@@ -74,8 +127,11 @@ const esc = {
74
127
  // 落在内容区 → 复制当前选区(若有)到剪贴板(clipboard.ts),静默不弹提示。
75
128
  // - 滚轮报表(button&64)转 scrollBy。
76
129
  // 代价:终端原生框选被鼠标捕获接管——想用终端原生选区可按住 Shift(多数终端放行)。
77
- mouseOn: '\x1B[?1000h\x1B[?1002h\x1B[?1006h',
78
- mouseOff: '\x1B[?1006l\x1B[?1002l\x1B[?1000l', // 关:反序
130
+ // 1007l=关闭 Alternate Scroll Mode:VSCode xterm.js 进 alt screen(?1049)后默认开 1007,
131
+ // 滚轮发 Up/Down 方向键而非 SGR 鼠标报表;不关的话滚轮会绕过 mouse.swallow 直接触发
132
+ // prompt 的历史导航(recallPrevious/recallNext)→ "输入框打字时滚轮跳到上一条历史消息"。
133
+ mouseOn: '\x1B[?1007l\x1B[?1000h\x1B[?1002h\x1B[?1006h',
134
+ mouseOff: '\x1B[?1006l\x1B[?1002l\x1B[?1000l\x1B[?1007h', // 关:反序;末尾恢复 1007(交还终端默认)
79
135
  cursorShow: '\x1B[?25h',
80
136
  cursorHide: '\x1B[?25l',
81
137
  clearLine: '\x1B[2K',
@@ -119,13 +175,23 @@ export function setRegion(fh) {
119
175
  contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
120
176
  return g;
121
177
  }
122
- /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+4(resize 安全),列对齐 renderDimInputRow 的假光标(空→3,有字→❯+截断文本宽+1)。供 IME 锚定。 */
178
+ /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+4(resize 安全)
179
+ * 非 dim 运行态(与空闲态同色、可任意位置编辑)→ 归当前编辑位,供 IME 锚定气泡到光标处;
180
+ * dim 占位(空 + placeholder)兼容态→ 归输入框起点。供 IME 锚定。 */
123
181
  function runningCaretPos() {
124
182
  const g = getGeo();
183
+ const row = g.contentBottom + 4;
184
+ if (lastView && !lastView.dim) {
185
+ const promptW = displayWidth(lastView.prompt);
186
+ const line = lastView.lines[lastView.cursorLine] ?? '';
187
+ const before = line.slice(0, lastView.cursorCol);
188
+ const col = Math.min(g.cols, promptW + displayWidth(before) + 1);
189
+ return { row, col };
190
+ }
125
191
  const text = lastView?.dim ? lastView.lines[0] ?? '' : '';
126
192
  const contentW = Math.max(0, g.cols - 3); // ❯ =2 + 光标=1
127
193
  const w = displayWidth(truncateDisplayHead(text, contentW));
128
- return { row: g.contentBottom + 4, col: Math.min(g.cols, 2 + w + 1) };
194
+ return { row, col: Math.min(g.cols, 2 + w + 1) };
129
195
  }
130
196
  export function contentMode() {
131
197
  if (!active)
@@ -476,6 +542,11 @@ export function clearContent() {
476
542
  // /theme、/clear、/resume 等命令 clearContent 后紧接 writeBanner 的场景均依赖此重置。
477
543
  bannerH = 0;
478
544
  bannerRows = [];
545
+ // 清内容区必须同时作废旧菜单擦除坐标:picker(/resume /rollback /theme)把菜单画在内容区底部,
546
+ // 菜单行号缓存在 lastMenuStartRow/lastMenuRows;若不清零,后续 paintInput 会按旧坐标“擦菜单”,
547
+ // 把刚 renderHistory/contentWrite 写好的内容(如“已续接会话”提示)清掉,导致用户要滚动一下才刷新。
548
+ lastMenuStartRow = 0;
549
+ lastMenuRows = 0;
479
550
  content.reset();
480
551
  notifyContentReset(); // batch 渲染器同步重置(batch 摘要行索引全部失效)
481
552
  stdout.write(esc.home);
@@ -1789,34 +1860,67 @@ function renderDimInputRow(prompt, text, placeholder, cols) {
1789
1860
  return `${ui.dim}${prompt}${p}${ui.reset}`;
1790
1861
  }
1791
1862
  /**
1792
- * 运行态 typeahead 回显:定向写输入行(底栏输入框),把 dim 占位换成已打字文本 + 反白块状光标(无打字时光标在起点)
1793
- * 只 cup 输入行 + clearLine + dim 文本 + 归位——不调 setRegion(运行中禁多行,避免 DECSTBM 抖动)、
1794
- * 不重画状态行/contentBottom、不用 ED。同步 lastView 为 dim 视图(text 与 placeholder 拆开存),使 scrollBy/resize
1795
- * 的 repaint 仍显当前回显 + 光标。真光标归续写位(滚动回看时归内容区底)——不入输入框,假光标已画在行内。
1863
+ * 单行运行态滑窗:以光标为中心,向左右扩展填满 contentW-1( 1 cell 给光标),
1864
+ * 返回可见子串与光标在子串内的显示列。保证光标恒可见,且不软折行(运行态输入框恒单行,
1865
+ * 不触发 setRegion/ED,避免流式期间底栏抖动——见 scripts/check-layout.ts 断言)
1796
1866
  */
1797
- export function paintRunningInputEcho(text, placeholder) {
1867
+ function windowSingleLine(text, cursor, contentW) {
1868
+ const n = text.length;
1869
+ if (n === 0)
1870
+ return { shown: '', curDisp: 0 };
1871
+ const totalDisp = displayWidth(text);
1872
+ if (totalDisp <= contentW)
1873
+ return { shown: text, curDisp: displayWidth(text.slice(0, cursor)) };
1874
+ let i = cursor;
1875
+ let j = cursor;
1876
+ let w = 0;
1877
+ const cw = (idx) => charWidth(text.codePointAt(idx) ?? 0);
1878
+ while (i > 0 && w + cw(i - 1) <= contentW - 1) {
1879
+ w += cw(i - 1);
1880
+ i--;
1881
+ }
1882
+ while (j < n && w + cw(j) <= contentW - 1) {
1883
+ w += cw(j);
1884
+ j++;
1885
+ }
1886
+ return { shown: text.slice(i, j), curDisp: displayWidth(text.slice(i, cursor)) };
1887
+ }
1888
+ /**
1889
+ * 运行态 typeahead 回显:定向写输入行(底栏输入框),非 dim(与空闲态同色)、光标可任意位置、单行。
1890
+ * 只 cup 输入行 + clearLine + 文本 + cup 真光标到编辑位——不调 setRegion(运行中禁多行,避免 DECSTBM 抖动)、
1891
+ * 不重画状态行/contentBottom、不用 ED。同步 lastView 为运行视图(非 dim),使 scrollBy/resize 的 repaint
1892
+ * 仍显当前回显 + 光标。空且带 placeholder 时显 dim ghost(提示 agent 状态;非用户输入,不违反"打字不变灰")。
1893
+ * 选区高亮由 paintInput 路径(paint 时鼠标拖选)承担,此处轻量不重复实现。
1894
+ */
1895
+ export function paintRunningInput(text, cursor, placeholder) {
1798
1896
  if (!active || !base)
1799
1897
  return;
1800
1898
  const g = getGeo();
1801
- const inputRow = g.contentBottom + 4; // 运行态 footerH 恒 5:虚拟空(+1)+状态(+2)+上线(+3)+输入行(+4);下线在 rows
1802
- // 先同步 lastView( runningCaretPos 算真光标位 = 新文本末尾,与假光标同位)
1899
+ const inputRow = g.contentBottom + 4; // 运行态 footerH 恒 6(单行,无 setRegion)
1900
+ const promptW = displayWidth('❯ ');
1901
+ const contentW = Math.max(1, g.cols - promptW);
1902
+ let outLine;
1903
+ let curCol; // 输入框内的显示列(不含 prompt)
1904
+ if (text.length === 0 && placeholder) {
1905
+ outLine = `${ui.dim}❯ ${truncateDisplay(placeholder, contentW)}${ui.reset}`;
1906
+ curCol = 0;
1907
+ }
1908
+ else {
1909
+ const { shown, curDisp } = windowSingleLine(text, cursor, contentW);
1910
+ outLine = `❯ ${shown}`; // 正常色 —— 运行态打字与空闲态一致
1911
+ curCol = curDisp;
1912
+ }
1803
1913
  lastView = {
1804
1914
  prompt: '❯ ',
1805
1915
  lines: [text],
1806
1916
  placeholder,
1807
1917
  cursorLine: 0,
1808
- cursorCol: 0,
1918
+ cursorCol: cursor,
1809
1919
  menu: null,
1810
- dim: true,
1920
+ // 不置 dim:运行态输入框与空闲态同色、可任意位置编辑
1811
1921
  };
1812
- // 单次 write:cup 输入行 + clearLine + dim 文本/假光标 + cup 真光标到输入框(供 IME 锚定)
1813
- // 滚动态也归输入框——旧设计滚动态归 contentBottom,致 IME 候选气泡锚到内容区底白块
1814
- // (conhost IME 不跟随 cup 后续移动,须让打字前光标已在输入框);拆两次 write 会暂留 contentBottom 显白块。
1815
- const p = runningCaretPos();
1816
- stdout.write(cup(inputRow, 1) +
1817
- esc.clearLine +
1818
- renderDimInputRow('❯ ', text, placeholder, g.cols) +
1819
- cup(p.row, p.col));
1922
+ const cursorCol = Math.min(g.cols, promptW + curCol + 1);
1923
+ stdout.write(cup(inputRow, 1) + esc.clearLine + outLine + cup(inputRow, cursorCol));
1820
1924
  }
1821
1925
  /** 重画当前视图(resize / 内部用)。 */
1822
1926
  export function repaint() {
@@ -1860,7 +1964,7 @@ export function enterInputMode(status = t('repl.idle')) {
1860
1964
  stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
1861
1965
  }
1862
1966
  }
1863
- /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 6(虚拟空+spinner行+上线+输入+下线+model行)。新轮回尾(确保新内容可见)。 */
1967
+ /** 进入运行态:底栏输入行与空闲态同色(非 dim)、可任意位置编辑,cursor 留输入框。footerH 恒 6(虚拟空+spinner行+上线+输入+下线+model行)。新轮回尾(确保新内容可见)。 */
1864
1968
  export function enterRunningMode(status, placeholder) {
1865
1969
  mode = 'running';
1866
1970
  statusText = status;
@@ -1877,7 +1981,6 @@ export function enterRunningMode(status, placeholder) {
1877
1981
  cursorLine: 0,
1878
1982
  cursorCol: 0,
1879
1983
  menu: null,
1880
- dim: true,
1881
1984
  });
1882
1985
  startTurnTimer(); // 续刷状态行走时(流式期间 spinner 停转,由它兜底)
1883
1986
  contentMode();
@@ -1944,12 +2047,14 @@ export function enterAltScreen() {
1944
2047
  resizeTimer?.unref?.();
1945
2048
  };
1946
2049
  process.on('SIGWINCH', sigwinchHandler);
2050
+ installConsoleGuard(); // 进入 TUI 即接管裸 console 输出,防第三方日志污染输入框
1947
2051
  }
1948
2052
  /** 复原:复位 margins + 显光标 + 退 alt + 还原 raw。幂等。 */
1949
2053
  export function exitAltScreen() {
1950
2054
  if (!active)
1951
2055
  return;
1952
2056
  active = false;
2057
+ uninstallConsoleGuard(); // 退出 TUI 恢复原始 console(不影响 host 子进程 / 退出日志)
1953
2058
  resetTerminalBackground();
1954
2059
  stopTurnTimer(); // 兜底清走时计时器(防异常退出泄漏)
1955
2060
  turnStart = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {