mocode-ai 1.2.1 → 1.2.3

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.
@@ -337,9 +337,20 @@ export async function runAgentCore(opts) {
337
337
  const modelStartedAt = Date.now();
338
338
  const provider = safeProviderId(requestBaseURL);
339
339
  emitTrace('model_start', { model: requestModel, provider });
340
- const dynamicSystemSuffix = historyRebuilt
341
- ? '## Post-compaction recovery\nContext was compacted before this request. Re-establish the current objective and unresolved work from retained evidence or the session note, avoid repeating completed investigation, and re-read exact file context before any dependent edit.'
342
- : '';
340
+ // 动态注入(仅追加到 system 末尾,不触碰 staticBody 前缀 → 不破坏 prompt 缓存):
341
+ // - 开场分析:仅主线(step===0 !suppressOpeningAnalysis)注入——即"用户发一个任务后,
342
+ // agent 第一次模型调用"。子代理经 spawn.ts 传 suppressOpeningAnalysis:true 排除。
343
+ // - historyRebuilt:compact 恢复步;要求重新锚定目标。两者可叠加但互不串扰。
344
+ // .filter(Boolean) 保证空段不产生多余空行;后续 step 均为空串 → system 前缀稳定命中缓存。
345
+ // 安全保证:此段只拼进 requestHistory(新建对象),绝不回写 history[0],故不会跨 step/跨 turn 残留。
346
+ const dynamicSystemSuffix = [
347
+ (!opts.suppressOpeningAnalysis && step === 0)
348
+ ? '## Opening analysis\nBegin your FIRST response of this turn with a brief analysis of the request and your planned approach (1-3 sentences, no filler), THEN start tool calls. This opening is the only place where pre-tool prose is expected; after it, work quietly with no narration between tool calls.'
349
+ : '',
350
+ historyRebuilt
351
+ ? '## Post-compaction recovery\nContext was compacted before this request. Re-establish the current objective and unresolved work from retained evidence or the session note, avoid repeating completed investigation, and re-read exact file context before any dependent edit.'
352
+ : '',
353
+ ].filter(Boolean).join('\n\n');
343
354
  const systemMessage = history[0];
344
355
  const requestHistory = dynamicSystemSuffix
345
356
  && systemMessage?.role === 'system'
@@ -225,6 +225,7 @@ export async function spawnAgent(opts) {
225
225
  maxSteps,
226
226
  toolsOverride,
227
227
  contextState: localContextState,
228
+ suppressOpeningAnalysis: true, // 子代理不注入「开场分析」:仅主线面对用户的首次响应用
228
229
  onToolOutcome: (tool, args) => {
229
230
  if (tool === 'read_file' && typeof args.path === 'string')
230
231
  readSet.add(args.path);
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import * as fsp from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { config } from '../config/index.js';
5
6
  import { truncateDisplay } from '../ui/render.js';
@@ -50,8 +51,19 @@ function readState(full) {
50
51
  }
51
52
  return { kind: 'missing' };
52
53
  }
54
+ /**
55
+ * 状态等价判定。两侧都捕获了内容时按内容比(与旧行为一致,精确);
56
+ * 任一侧内容未捕获(超预算的大文件)时退化为 stamp 比较——size+mtimeNs 变了就算变。
57
+ */
53
58
  function sameState(a, b) {
54
- return a.kind === b.kind && a.data === b.data && a.mode === b.mode;
59
+ if (a.kind !== b.kind || a.mode !== b.mode)
60
+ return false;
61
+ if (a.data !== undefined && b.data !== undefined)
62
+ return a.data === b.data;
63
+ if (a.data === undefined && b.data === undefined && a.stamp === undefined && b.stamp === undefined) {
64
+ return true; // directory / missing:kind+mode 已足够
65
+ }
66
+ return a.stamp === b.stamp;
55
67
  }
56
68
  function stateFingerprint(state) {
57
69
  return createHash('sha256')
@@ -82,6 +94,8 @@ function snapshotFromState(rel, state, sequence, op, createdParents = [], after)
82
94
  ops: [op],
83
95
  createdParents: createdParents.length > 0 ? createdParents : undefined,
84
96
  afterFingerprint: after ? stateFingerprint(after) : undefined,
97
+ // 文件但没有内容 = 工作区扫描时超出捕获预算,只能报告"变了",不能拿它覆盖磁盘。
98
+ contentUnavailable: state.kind === 'file' && state.data === undefined ? true : undefined,
85
99
  };
86
100
  }
87
101
  /** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
@@ -163,55 +177,172 @@ export function endPathMutation(capture, op) {
163
177
  if (changed)
164
178
  mutationVersion += 1;
165
179
  }
166
- // 构建产物 / 临时 / 缓存目录:可再生运行时状态,扫描它们既昂贵(dist/ 含大量 .js bundle,
167
- // 全量 readFileSync 会同步卡死事件循环,表现为 run_command 期间滚轮划不动、spinner 冻结),
168
- // 也易把后台 daemon / 打包器的写入误判成模型改动。回滚本就只应覆盖源码,构建产物可再生。
180
+ // 构建产物 / 依赖树 / 缓存 / 运行时状态目录:可再生,扫描它们既昂贵也易把后台 daemon、
181
+ // 打包器、mocode 自身(会话日志 / dev-server 日志 / 截图)的写入误判成模型改动。
182
+ // 回滚本就只应覆盖源码。
169
183
  const EXCLUDED_WORKSPACE_DIRS = new Set([
170
- '.git', '.codegraph', 'node_modules',
171
- 'dist', 'build', 'out', 'coverage', '.tmp', 'tmp',
172
- '.output', '.next', '.vite', '.turbo', '.svelte-kit',
184
+ // VCS / 索引 / mocode 自身运行时状态(会话、trace、dev-server 日志、记忆、截图每轮都在写,
185
+ // 既无回滚意义,又会被误判成模型改动)
186
+ '.git', '.hg', '.svn', '.codegraph', '.mocode',
187
+ // 依赖树与包管理器缓存
188
+ 'node_modules', 'vendor', 'bower_components', '.yarn', '.pnpm-store', '.venv', 'venv', 'pods',
189
+ // 构建产物
190
+ 'dist', 'build', 'out', 'target', 'coverage', '.output', '.next', '.nuxt', '.vite',
191
+ '.turbo', '.svelte-kit', '.angular', '.astro', '.docusaurus', '.dart_tool', '.terraform',
192
+ // 临时与缓存
193
+ '.tmp', 'tmp', '.cache', '.parcel-cache', '.nyc_output', '__pycache__',
194
+ '.pytest_cache', '.mypy_cache', '.ruff_cache', '.gradle',
173
195
  ]);
196
+ /** 单文件内容捕获上限:更大的文件只留 stamp(可检测变化,不可恢复),避免把巨型二进制读进内存。 */
197
+ const CAPTURE_FILE_LIMIT = 1024 * 1024;
198
+ /** 单次扫描的内容总预算:超出后剩余文件只留 stamp。 */
199
+ const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
200
+ /** 条目上限:超大工作区不做无边界遍历(超出部分不参与变更检测)。 */
201
+ const CAPTURE_ENTRY_LIMIT = 20000;
202
+ /** 并发文件操作数:冷缓存(尤其 Windows 杀软逐文件扫描)下 I/O 重叠远快于串行。 */
203
+ const SCAN_CONCURRENCY = 16;
204
+ /** 让出事件循环的节奏:每 N 个条目,或每累计编码 M 字节(base64 是纯 CPU,大文件靠字节数兜底)。 */
205
+ const YIELD_EVERY = 64;
206
+ const YIELD_BYTES = 1024 * 1024;
207
+ /**
208
+ * 刚被写过的文件不信缓存,强制重读内容。
209
+ * 原因:stamp 依赖 mtime 精度。NTFS/ext4/APFS 是 100ns~ns 级,但 exFAT / 部分网络盘只有 1~2s,
210
+ * 那里一条"同尺寸原地改写"可能与快照前共享同一时间戳,只比 stamp 会漏掉真实变化。
211
+ * 只对最近 2s 内改动的文件付重读代价(通常正是命令刚碰过的那几个),开销可忽略。
212
+ */
213
+ const FRESH_WINDOW_MS = 2000;
214
+ /** path → 上次扫描捕获的内容。stamp(size+mtimeNs)未变即复用,未改动的文件不重复读盘。 */
215
+ let contentCache = new Map();
216
+ let cachedSessionDir = '';
217
+ let resolvedSessionDir = '';
218
+ function sessionDirAbs() {
219
+ if (config.sessionDir !== cachedSessionDir) {
220
+ cachedSessionDir = config.sessionDir;
221
+ resolvedSessionDir = path.resolve(config.sessionDir);
222
+ }
223
+ return resolvedSessionDir;
224
+ }
174
225
  function isWorkspaceExcluded(full) {
175
226
  const base = path.basename(full).toLowerCase();
176
227
  // Git 元数据必须永久排除以保护 index;依赖树/代码索引/构建产物/临时目录是可再生运行时状态。
177
228
  if (EXCLUDED_WORKSPACE_DIRS.has(base))
178
229
  return true;
179
- const sessionDir = path.resolve(config.sessionDir);
180
- return isInside(sessionDir, full);
230
+ return isInside(sessionDirAbs(), full);
181
231
  }
182
- function scanWorkspace() {
232
+ const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
233
+ /**
234
+ * 工作区快照(run_command / dev_server / MCP 等不透明工具用)。
235
+ *
236
+ * **必须是异步且带缓存的**:本函数在每次这类工具调用前后各跑一次,直接坐在用户交互
237
+ * 路径上。旧实现用 readdirSync + 全量 readFileSync(base64) 同步遍历整棵工作树 ——
238
+ * 冷文件缓存下 400 个文件实测就要 ~3s(Windows),几千个文件即数十秒,期间事件循环
239
+ * 完全阻塞:spinner 冻结、走时停摆、键鼠无响应,用户看到的就是「卡在 执行 run_command」。
240
+ *
241
+ * 现在:
242
+ * 1) fs/promises + 有界并发 + 定期 setImmediate 让出 → 事件循环全程可呼吸;
243
+ * 2) 内容按 (size, mtimeNs, mode) 缓存复用 → 未改动的文件不再重复读盘,
244
+ * 一次会话里只有首次扫描付全量代价,之后只读真正变化的文件;
245
+ * 3) 单文件 / 总量 / 条目数三重预算 → 巨型仓库不会把内存和时间吃穿。
246
+ */
247
+ async function scanWorkspace() {
183
248
  const entries = new Map();
184
- const walk = (dir) => {
249
+ const nextCache = new Map();
250
+ const paths = [];
251
+ // 第一趟:只 readdir 收集条目(廉价,不读内容)。目录本身即刻记账,顺序稳定。
252
+ const walk = async (dir) => {
253
+ if (paths.length >= CAPTURE_ENTRY_LIMIT)
254
+ return;
185
255
  let children;
186
256
  try {
187
- children = readdirSync(dir, { withFileTypes: true });
257
+ children = await fsp.readdir(dir, { withFileTypes: true });
188
258
  }
189
259
  catch {
190
260
  return;
191
261
  }
192
262
  for (const child of children) {
263
+ if (paths.length >= CAPTURE_ENTRY_LIMIT)
264
+ return;
193
265
  const full = path.join(dir, child.name);
194
266
  if (isWorkspaceExcluded(full))
195
267
  continue;
196
- const state = readState(full);
268
+ paths.push(full);
269
+ // isDirectory() 对 symlink 为 false —— 与旧实现一致:不跟随软链。
270
+ if (child.isDirectory())
271
+ await walk(full);
272
+ }
273
+ };
274
+ await walk(rootDir());
275
+ // 第二趟:有界并发读状态。budget 由完成顺序消费——两次扫描间某个大文件是否被捕获
276
+ // 可能不同,sameState 在任一侧缺内容时退化为 stamp 比较,故不会产生伪变化。
277
+ let budget = CAPTURE_TOTAL_LIMIT;
278
+ let cursor = 0;
279
+ let processed = 0;
280
+ let bytesSinceYield = 0;
281
+ const worker = async () => {
282
+ while (cursor < paths.length) {
283
+ const full = paths[cursor++];
284
+ if (++processed % YIELD_EVERY === 0 || bytesSinceYield >= YIELD_BYTES) {
285
+ bytesSinceYield = 0;
286
+ await yieldToEventLoop();
287
+ }
288
+ let state;
289
+ try {
290
+ const stat = await fsp.lstat(full, { bigint: true });
291
+ const mode = Number(stat.mode) & 0o777;
292
+ if (stat.isSymbolicLink()) {
293
+ const target = await fsp.readlink(full);
294
+ state = { kind: 'symlink', data: target, stamp: target, mode };
295
+ }
296
+ else if (stat.isDirectory()) {
297
+ state = { kind: 'directory', mode, stamp: '' };
298
+ }
299
+ else if (stat.isFile()) {
300
+ const size = Number(stat.size);
301
+ const stamp = `${size}:${stat.mtimeNs}`;
302
+ // 未来时间戳(时钟偏移)同样按"新鲜"处理 —— 宁可多读一次,不可漏判变化。
303
+ const fresh = Date.now() - Number(stat.mtimeNs / 1000000n) < FRESH_WINDOW_MS;
304
+ const cached = fresh ? undefined : contentCache.get(full);
305
+ if (cached && cached.stamp === stamp && cached.mode === mode) {
306
+ budget -= size; // 命中也计预算,保证冷/热缓存下的捕获集合一致
307
+ state = { kind: 'file', data: cached.data, stamp, mode };
308
+ nextCache.set(full, cached);
309
+ }
310
+ else if (size <= CAPTURE_FILE_LIMIT && budget - size >= 0) {
311
+ budget -= size;
312
+ bytesSinceYield += size;
313
+ const data = (await fsp.readFile(full)).toString('base64');
314
+ state = { kind: 'file', data, stamp, mode };
315
+ nextCache.set(full, { stamp, mode, data });
316
+ }
317
+ else {
318
+ // 超大文件 / 预算耗尽:只留指纹,能报告变化但不参与内容恢复。
319
+ state = { kind: 'file', stamp, mode };
320
+ }
321
+ }
322
+ else {
323
+ state = { kind: 'missing' };
324
+ }
325
+ }
326
+ catch {
327
+ state = { kind: 'missing' }; // 不存在或不可读:工具若最终也不可读,不会产生伪变化
328
+ }
197
329
  if (state.kind === 'missing')
198
330
  continue;
199
- const rel = toRel(full);
200
- entries.set(rel, state);
201
- if (state.kind === 'directory')
202
- walk(full);
331
+ entries.set(toRel(full), state);
203
332
  }
204
333
  };
205
- walk(rootDir());
334
+ await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
335
+ // 缓存整体替换:已删除的文件自然被淘汰,内存上限 ≈ CAPTURE_TOTAL_LIMIT。
336
+ contentCache = nextCache;
206
337
  return entries;
207
338
  }
208
339
  /** run_command/MCP 前调用。不跟随 symlink,并排除 .git、会话快照、依赖树与代码索引。 */
209
- export function beginWorkspaceMutation() {
210
- return { sequence: ++sequenceCounter, entries: scanWorkspace() };
340
+ export async function beginWorkspaceMutation() {
341
+ return { sequence: ++sequenceCounter, entries: await scanWorkspace() };
211
342
  }
212
343
  /** 不透明工具执行后比较整个工作区,把实际变化压入当前轮事务日志。 */
213
- export function endWorkspaceMutation(capture, op) {
214
- const after = scanWorkspace();
344
+ export async function endWorkspaceMutation(capture, op) {
345
+ const after = await scanWorkspace();
215
346
  const paths = new Set([...capture.entries.keys(), ...after.keys()]);
216
347
  let changed = false;
217
348
  for (const rel of paths) {
@@ -234,10 +365,16 @@ export function getCurrentTurnMutationState() {
234
365
  continue;
235
366
  let change = byPath.get(snapshot.path);
236
367
  if (!change) {
237
- change = { path: snapshot.path, ops: [], snapshotAvailable: true };
368
+ change = {
369
+ path: snapshot.path,
370
+ ops: [],
371
+ snapshotAvailable: snapshot.contentUnavailable !== true,
372
+ };
238
373
  byPath.set(snapshot.path, change);
239
374
  order.push(snapshot.path);
240
375
  }
376
+ if (snapshot.contentUnavailable)
377
+ change.snapshotAvailable = false;
241
378
  for (const op of snapshot.ops ?? ['file_change']) {
242
379
  if (!change.ops.includes(op))
243
380
  change.ops.push(op);
@@ -281,6 +418,8 @@ export function planRollback(n, history) {
281
418
  if (snapshot.turnId <= cutoffTurnId)
282
419
  continue;
283
420
  const change = ensure(snapshot.path);
421
+ if (snapshot.contentUnavailable)
422
+ change.snapshotAvailable = false;
284
423
  for (const op of snapshot.ops ?? ['file_change']) {
285
424
  if (!change.ops.includes(op))
286
425
  change.ops.push(op);
@@ -300,6 +439,9 @@ function restoreSnapshot(snapshot) {
300
439
  const full = safeFullPath(snapshot.path);
301
440
  if (!full)
302
441
  return false;
442
+ // 内容未捕获(工作区扫描时超出预算):宁可报冲突,也不能拿空内容覆盖用户文件。
443
+ if (snapshot.contentUnavailable)
444
+ return false;
303
445
  const state = stateFromSnapshot(snapshot);
304
446
  try {
305
447
  if (state.kind === 'missing') {
@@ -413,6 +555,7 @@ export function resetState() {
413
555
  turnIdCounter = 0;
414
556
  currentTurnId = 0;
415
557
  sequenceCounter = 0;
558
+ contentCache = new Map();
416
559
  }
417
560
  export function rebuildFromHistory(history) {
418
561
  const rebuilt = [];
@@ -148,8 +148,10 @@ async function executeToolOnce(tool, args, signal, opts) {
148
148
  ? beginPathMutation(args.path)
149
149
  : null;
150
150
  capturedPath = pathCapture?.path;
151
+ // 工作区快照是异步的:它遍历整棵工作树,同步实现会在每次 run_command/MCP 调用前后
152
+ // 阻塞事件循环数秒(TUI 完全冻结)。await 让 spinner / 走时 / 键鼠在扫描期间继续工作。
151
153
  const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
152
- ? beginWorkspaceMutation()
154
+ ? await beginWorkspaceMutation()
153
155
  : null;
154
156
  let raw;
155
157
  try {
@@ -159,7 +161,7 @@ async function executeToolOnce(tool, args, signal, opts) {
159
161
  if (pathCapture)
160
162
  endPathMutation(pathCapture, tool.name);
161
163
  if (workspaceCapture)
162
- endWorkspaceMutation(workspaceCapture, tool.name);
164
+ await endWorkspaceMutation(workspaceCapture, tool.name);
163
165
  }
164
166
  const mutationAfter = getCurrentTurnMutationState();
165
167
  const changedFiles = mutationAfter.version !== mutationBefore.version
@@ -210,7 +210,7 @@ export async function promptIntervention(req) {
210
210
  layout.resetScroll();
211
211
  else
212
212
  layout.repaintViewport();
213
- // 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 200ms 刷新。
213
+ // 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 80ms 刷新。
214
214
  layout.startTurnTimerIfRunning();
215
215
  }
216
216
  function onKey(_str, key) {
@@ -339,7 +339,7 @@ export async function promptIntervention(req) {
339
339
  return new Promise((res, rej) => {
340
340
  resolve = res;
341
341
  try {
342
- // 进入面板:停 spinner(避免 onFrame 覆盖)+ 停走时计时器(避免 drawStatusBar 200ms 心跳
342
+ // 进入面板:停 spinner(避免 onFrame 覆盖)+ 停走时计时器(避免 drawStatusBar 80ms 心跳
343
343
  // 把真光标拉到 runningCaretPos 覆盖 paintInput 的正确光标位)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
344
344
  Spinner.pauseCurrent();
345
345
  layout.stopTurnTimer();
package/dist/ui/layout.js CHANGED
@@ -82,7 +82,7 @@ const SCROLL_LOCK_MS = 400; // 锁时长:覆盖 OS 缓冲残留 + 常规滚轮
82
82
  let base = null;
83
83
  // 运行态实时 token 用量(agent core 流式推送,轮末 repl 清 undefined)。
84
84
  // composeModelLine 在 RUNNING 态把它画成 chip 放 context 进度条左侧;
85
- // 不主动触发重画——RUNNING 态 turnTimer 200ms 心跳重画自然取最新值。
85
+ // 不主动触发重画——RUNNING 态 turnTimer 80ms 心跳重画自然取最新值。
86
86
  let liveUsage;
87
87
  let statusText = '';
88
88
  let spinnerFrame;
@@ -92,8 +92,8 @@ let turnTimer = null; // 走时刷新计时器(独立于 spinner):流式期间 s
92
92
  * 驱动 paintInput setRegion(fh) 动态撑高脚栏;drawStatusBar 据此画 1/2 行 plan,
93
93
  * 与 spinner/上线/输入/下线/model 行的 +1/+2/+3/+4/+5 偏移天然一致(contentBottom 自动重算)。 */
94
94
  let planRows = 1;
95
- let runningFrame = -1; // 运行态状态行 chip 心跳帧(♥/♡ 明灭);INPUT 态 -1 退回静态 ●
96
- const RUNNING_FRAMES = ['', ''];
95
+ let runningFrame = -1; // 运行态状态行 chip 心跳帧(转圈帧 明灭);INPUT 态 -1 退回静态 ●
96
+ const RUNNING_FRAMES = ['', '', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
97
97
  // 运行态用户打字时暂停流式物理写:流式每个 token 要 cup 到 contentRow 写入,IME 候选窗逐光标移动跟踪会跟过去;
98
98
  // 用户打字期间只喂缓冲、不物理写,光标留输入框;停手 USER_ACTIVE_PAUSE_MS 后 flush 重画缓冲内容。
99
99
  let userActiveUntil = 0; // 打字活跃截止时刻(Date.now()+PAUSE);0=未活跃
@@ -1332,7 +1332,7 @@ function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
1332
1332
  * 示例:
1333
1333
  * INPUT: ● 空闲
1334
1334
  * 思考中: ⠹ 思考中… 0.5s
1335
- * 运行心跳: 0.5s
1335
+ * 运行心跳: 生成中 0.5s
1336
1336
  * 滚动回看: ● 空闲 历史 ↑3 (PgDn 回底) */
1337
1337
  function composeSpinnerLine(status, cols) {
1338
1338
  const spinning = mode === 'running' && runningFrame >= 0;
@@ -1361,10 +1361,11 @@ function composeSpinnerLine(status, cols) {
1361
1361
  leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1362
1362
  }
1363
1363
  else if (spinning) {
1364
- // 运行态心跳帧(流式输出中):帧 + 走时
1364
+ // 运行态心跳帧(流式输出中):帧 + 生成中 + 走时
1365
+ const label = '生成中';
1365
1366
  const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1366
- lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset}${ePart}`;
1367
- leadW = 1 + (elapsed ? 1 + displayWidth(elapsed) : 0);
1367
+ lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset} ${ui.dim}${label}${ui.reset}${ePart}`;
1368
+ leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1368
1369
  }
1369
1370
  else {
1370
1371
  // INPUT 态:● + 状态文字(无走时)
@@ -1539,9 +1540,9 @@ export function setStatus(status, frame) {
1539
1540
  drawStatusBar();
1540
1541
  }
1541
1542
  /**
1542
- * 启走时刷新计时器:RUNNING 态每 200ms 重画状态行,使 composeStatus 重算 elapsed。
1543
+ * 启走时刷新计时器:RUNNING 态每 80ms 重画状态行,使 composeStatus 重算 elapsed。
1543
1544
  * 必要性:spinner 在首 token 到达即 stop,思考/正文流式期间状态行不再经 spinner 刷新;
1544
- * 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 重叠幂等无妨。
1545
+ * 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 同速,重叠幂等无妨。
1545
1546
  * 非 TTY 不启(active=false 时 drawStatusBar 为 no-op)。
1546
1547
  */
1547
1548
  function startTurnTimer() {
@@ -1552,10 +1553,10 @@ function startTurnTimer() {
1552
1553
  turnTimer = setInterval(() => {
1553
1554
  runningFrame = (runningFrame + 1) % RUNNING_FRAMES.length; // 推进心跳帧,让前导符跳动
1554
1555
  drawStatusBar();
1555
- }, 200);
1556
+ }, 80);
1556
1557
  turnTimer.unref();
1557
1558
  }
1558
- /** 停走时计时器。enterInputMode / exitAltScreen 调;intervention 面板进入时也调(防 drawStatusBar 200ms 心跳把光标拉到 runningCaretPos,覆盖 paintInput 的正确光标位)。 */
1559
+ /** 停走时计时器。enterInputMode / exitAltScreen 调;intervention 面板进入时也调(防 drawStatusBar 80ms 心跳把光标拉到 runningCaretPos,覆盖 paintInput 的正确光标位)。 */
1559
1560
  export function stopTurnTimer() {
1560
1561
  if (turnTimer) {
1561
1562
  clearInterval(turnTimer);
@@ -1646,7 +1647,7 @@ export function clearLiveAtCursor() {
1646
1647
  frameCol = 0;
1647
1648
  }
1648
1649
  /** 推送 / 清空运行态实时 token 用量(agent core 流式推送;repl 轮末清 undefined)。
1649
- * 不触发重画:RUNNING 态 turnTimer 200ms 心跳重画状态行,自然取到最新值。 */
1650
+ * 不触发重画:RUNNING 态 turnTimer 80ms 心跳重画状态行,自然取到最新值。 */
1650
1651
  export function setLiveUsage(u) {
1651
1652
  liveUsage = u;
1652
1653
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {