mocode-ai 1.6.9 → 1.7.0
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 +2 -0
- package/README.zh-CN.md +2 -0
- package/dist/agent/model-turn.js +6 -1
- package/dist/agent/run-coordinator.js +220 -149
- package/dist/agent/stages/tool-dispatcher.js +150 -126
- package/dist/headless.js +27 -1
- package/dist/jobs/launch.js +14 -7
- package/dist/jobs/worktree.js +20 -0
- package/dist/llm/index.js +64 -3
- package/dist/llm/proxy.js +24 -1
- package/dist/repl/commands/diff.js +189 -0
- package/dist/repl/commands/jobs.js +2 -2
- package/dist/repl/commands/registry.js +2 -0
- package/dist/repl/commands.js +1 -0
- package/dist/repl/runtime.js +29 -10
- package/dist/rollback/store.js +56 -1
- package/dist/runtime/dev-server-manager.js +27 -5
- package/dist/schedule/cli.js +1 -1
- package/dist/schedule/daemon.js +3 -3
- package/dist/session/store.js +108 -32
- package/dist/skills/builtin-skills.js +99 -59
- package/dist/tools/builtins/index.js +10 -2
- package/dist/tools/builtins/run-command.js +32 -10
- package/dist/tools/policy.js +2 -2
- package/dist/tools/resource-lock.js +45 -4
- package/dist/tools/ripgrep-client.js +9 -1
- package/dist/ui/batch.js +5 -4
- package/dist/ui/layout-internal/content-write.js +154 -17
- package/dist/ui/layout-internal/core.js +18 -1
- package/dist/ui/layout-internal/state.js +14 -2
- package/dist/verification/discovery.js +1 -1
- package/dist/verification/prompt.js +1 -1
- package/package.json +1 -1
|
@@ -9,6 +9,44 @@ import { renderMarkdown } from '../markdown.js';
|
|
|
9
9
|
import { state } from './state.js';
|
|
10
10
|
import { esc, cup, getGeo, setRegion, runningCaretPos } from './screen.js';
|
|
11
11
|
import { viewportAbsStart, repaintViewport } from './scroll.js';
|
|
12
|
+
// ── 流式 markdown 渲染节流(2026-10-05)──
|
|
13
|
+
// 背景:原实现每收到一个 onText chunk 就把**累积的整个 mdBuf** 重渲一遍(渲成自洽 ANSI 行
|
|
14
|
+
// 替换缓冲段 + repaintViewport 全画)。这是 O(n²):实测一次 5879 字符的回复被切成~1960 个
|
|
15
|
+
// chunk,累计扫描 5.5MB、纯 CPU ~900ms,且全程同步阻塞事件循环(TUI 的 spinner/输入都跟着卡)。
|
|
16
|
+
// renderMarkdown 内有 LRU memo(key = 完整 text),但流式下每次 text 都是新串 → 必然 miss。
|
|
17
|
+
// 分块归因实测:单次 render 仅 0.05ms 量级,带 lang 的代码块最贵(0.097ms),无 lang 近乎免费。
|
|
18
|
+
// → 结论是「贵在次数,不在单次」,故按时间/内容节流降次数,而不是优化单次渲染。
|
|
19
|
+
/**
|
|
20
|
+
* 两次渲染的最小间隔(ms)。
|
|
21
|
+
*
|
|
22
|
+
* 取 32ms(~30fps)而非 16ms:终端正文流式本来肉眼无法分辨更高帧率,32ms 把渲染次数压到
|
|
23
|
+
* 1/20 左右的量级而观感无差异。低于此值收益边际递减(受 pty 写吞吐约束),高于此值
|
|
24
|
+
* 长文本回复会有肉眼可见的「一段一段蹦」感。
|
|
25
|
+
*/
|
|
26
|
+
const MD_RENDER_INTERVAL_MS = 32;
|
|
27
|
+
/**
|
|
28
|
+
* 两次渲染的最小字符增量(**仅用于防饿死**,不是常规触发路径)。
|
|
29
|
+
*
|
|
30
|
+
* 后端可能一次性吐出一个大 chunk(如整段代码 / 工具参数),此时距上次渲染可能才过去
|
|
31
|
+
* 1~2ms,纯按时间判定会把它推迟到下个间隔才上屏。攒够这么多字符就立刻渲,保证
|
|
32
|
+
* 「有实质进展就尽快上屏」,同时仍拦住小碎 chunk 的逐个重渲。
|
|
33
|
+
*
|
|
34
|
+
* **取值的关键**:必须**远大于**「一个节流间隔内正常 chunk 的累积量」,否则它会抢在
|
|
35
|
+
* 时间节流前面触发,节流退化成「每 N 个 chunk 渲一次」—— 时间维度失效。
|
|
36
|
+
* 高速后端(约 5ms/chunk、1~2 token ≈ 5 字符)在一个 32ms 间隔内约累积 30 字符,
|
|
37
|
+
* 取 512 留出 17 倍余量:突发大段能快速上屏,正常流里则几乎只由时间节流驱动。
|
|
38
|
+
*/
|
|
39
|
+
const MD_RENDER_BURST_CHARS = 512;
|
|
40
|
+
/**
|
|
41
|
+
* 节流判定(纯函数,导出供单测 —— TUI 渲染路径本身需要真 TTY,无法在测试里驱动)。
|
|
42
|
+
*
|
|
43
|
+
* @param elapsedNow距上次渲染的毫秒数
|
|
44
|
+
* @param pendingChars 本次 chunk 累积后、尚未渲染的字符数
|
|
45
|
+
* @returns true=本次就渲染;false=只累积并挂补渲定时器
|
|
46
|
+
*/
|
|
47
|
+
export function shouldRenderMdSegment(elapsedNow, pendingChars) {
|
|
48
|
+
return elapsedNow >= MD_RENDER_INTERVAL_MS || pendingChars >= MD_RENDER_BURST_CHARS;
|
|
49
|
+
}
|
|
12
50
|
/**
|
|
13
51
|
* 内容区正文写:CUP 到续写位 + 写出 + 按字符宽度/折行更新续写位 + 同步入 content 缓冲(供滚动回看)。
|
|
14
52
|
* 滚动区域内写满自动在区域内滚动,底栏在区域外不被顶。非 TTY / 未激活:直接 stdout.write(内联退化)。
|
|
@@ -164,13 +202,25 @@ function beginMdSegment() {
|
|
|
164
202
|
content.beginSegment();
|
|
165
203
|
state.mdBuf = '';
|
|
166
204
|
state.mdActive = true;
|
|
205
|
+
state.mdLastRenderAt = 0;
|
|
206
|
+
state.mdRenderedLen = 0;
|
|
167
207
|
}
|
|
168
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。
|
|
210
|
+
*
|
|
211
|
+
* 必须先 flushMdSegment:commitSegment 会清 segMark,此后 setLines 不再截断到段头——若此时
|
|
212
|
+
* mdBuf 还有未渲的 chunk,那部分内容将永远进不了缓冲(静默丢字)。故「收尾」语义在这里
|
|
213
|
+
* 兑现:任何离开 md 段的路径都先把累积内容渲上屏。这也是节流的安全网:定时器只是兜底,
|
|
214
|
+
* 真正的收尾由本函数保证。
|
|
215
|
+
*/
|
|
169
216
|
function commitMd() {
|
|
170
217
|
if (!state.mdActive)
|
|
171
218
|
return;
|
|
219
|
+
flushMdSegment();
|
|
172
220
|
state.mdActive = false;
|
|
173
221
|
state.mdBuf = '';
|
|
222
|
+
state.mdLastRenderAt = 0;
|
|
223
|
+
state.mdRenderedLen = 0;
|
|
174
224
|
content.commitSegment();
|
|
175
225
|
}
|
|
176
226
|
// ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
|
|
@@ -235,25 +285,18 @@ export function rewriteBanner(lines) {
|
|
|
235
285
|
export function bannerHeight() {
|
|
236
286
|
return state.bannerH;
|
|
237
287
|
}
|
|
288
|
+
/** 本段已渲染进缓冲的 mdBuf 长度(节流的内容判定用;md 段外无意义故返回 0)。 */
|
|
289
|
+
function mdRenderedLen() {
|
|
290
|
+
return state.mdActive ? state.mdRenderedLen : 0;
|
|
291
|
+
}
|
|
238
292
|
/**
|
|
239
|
-
*
|
|
240
|
-
* mdBuf 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines 截旧 + 写新),
|
|
241
|
-
* repaintViewport 重画(仅尾 offset=0 且未暂停)。流式安全:未闭合 fence 照常 emit 进行中代码块;
|
|
242
|
-
* renderMarkdown 内部 memo by text 使重复渲染命中缓存。非 TTY / 未激活:直接 stdout.write(与
|
|
243
|
-
* contentWrite 一致,管道流式可见)。
|
|
293
|
+
* 把当前 mdBuf 渲成自洽行并 replace 缓冲段 + 重画 viewport。
|
|
244
294
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
295
|
+
* 从 contentWriteMd 抽出的独立步骤:节流路径在「攒够间隔」和「收尾强制补渲」两条路上
|
|
296
|
+
* 都要用到它。行为与原 contentWriteMd 渲染体逐行一致(滚动回看冻结、续写位推进、
|
|
297
|
+
* running 态光标归位都在此),调用方只负责决定**何时**调。
|
|
248
298
|
*/
|
|
249
|
-
|
|
250
|
-
if (!state.active || !ui.isTTY) {
|
|
251
|
-
stdout.write(s);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
if (!state.mdActive)
|
|
255
|
-
beginMdSegment();
|
|
256
|
-
state.mdBuf += s;
|
|
299
|
+
function renderMdSegment() {
|
|
257
300
|
const g = getGeo();
|
|
258
301
|
// 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
|
|
259
302
|
// 下次 scrollBy→repaintViewport 取漂移窗口、把流式正文画进历史视图。故 setLines 前后算 totalRows 差,
|
|
@@ -262,6 +305,7 @@ export function contentWriteMd(s) {
|
|
|
262
305
|
const totalBefore = scrolled ? content.totalRows() : 0;
|
|
263
306
|
const lines = renderMarkdown(state.mdBuf, g.cols);
|
|
264
307
|
content.setLines(lines, state.mdBuf);
|
|
308
|
+
state.mdRenderedLen = state.mdBuf.length;
|
|
265
309
|
const segRows = lines.length;
|
|
266
310
|
const available = g.contentBottom - state.segmentStartRow + 1;
|
|
267
311
|
state.contentRow = segRows >= available ? g.contentBottom : state.segmentStartRow + segRows;
|
|
@@ -273,6 +317,7 @@ export function contentWriteMd(s) {
|
|
|
273
317
|
state.scrollOffset = Math.max(0, Math.min(state.scrollOffset + delta, maxOff));
|
|
274
318
|
}
|
|
275
319
|
}
|
|
320
|
+
state.mdLastRenderAt = Date.now();
|
|
276
321
|
if (state.scrollOffset === 0) {
|
|
277
322
|
repaintViewport(); // 单次 write 结尾 cup 回 runningCaretPos(运行态),IME 锚输入框;打字中不再暂停
|
|
278
323
|
if (state.mode === 'running') {
|
|
@@ -281,6 +326,75 @@ export function contentWriteMd(s) {
|
|
|
281
326
|
}
|
|
282
327
|
}
|
|
283
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* 立即把mdBuf 渲进缓冲并重画,并撤销待补渲定时器。
|
|
331
|
+
*
|
|
332
|
+
* 所有「不能留待定时器补渲」的路径都必须走这里(收尾/ 换段 / resize / 清屏 / 退出):
|
|
333
|
+
* 缓冲内容是后续一切布局计算(contentInsertAfter 的 segMark 平移、contentRow 续写位、
|
|
334
|
+
* reflow 的 activeSegmentStart)的地基,留着未渲的 chunk 会让它们算错。
|
|
335
|
+
*
|
|
336
|
+
* 导出给 layout-internal/core 的 enterInputMode 用(轮末兜底补渲);core 只在 mdActive
|
|
337
|
+
* 时调,其余调用方是本模块内的 commitMd / clearContent。
|
|
338
|
+
*/
|
|
339
|
+
export function flushMdSegment() {
|
|
340
|
+
if (state.mdFlushTimer) {
|
|
341
|
+
clearTimeout(state.mdFlushTimer);
|
|
342
|
+
state.mdFlushTimer = null;
|
|
343
|
+
}
|
|
344
|
+
if (!state.mdActive)
|
|
345
|
+
return;
|
|
346
|
+
renderMdSegment();
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* markdown 正文写(替代 contentWrite 用于 agent onText):累积 chunk 到 mdBuf,**按节流节奏**
|
|
350
|
+
* 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines)+ repaintViewport 重画。
|
|
351
|
+
* 非 TTY / 未激活:直接 stdout.write(与 contentWrite 一致,管道流式可见)。
|
|
352
|
+
*
|
|
353
|
+
* **节流(2026-10-05)**:每 chunk 重渲整个累积 mdBuf 是 O(n²)(实测 5879 字符回复 →
|
|
354
|
+
* 1960 chunk → 累计扫描 5.5MB / ~900ms 纯 CPU),而 renderMarkdown 的 LRU memo 在流式下
|
|
355
|
+
* 必然 miss。改为:距上次渲染不足 MD_RENDER_INTERVAL_MS 时只累积 chunk 并挂补渲定时器。
|
|
356
|
+
*
|
|
357
|
+
* 渲染时机由**时间**主导(唯一正常路径的触发条件),内容增益只在「时间未到但已攒了
|
|
358
|
+
* 远超一帧正常量的字符」时放行:
|
|
359
|
+
* - elapsed ≥ MD_RENDER_INTERVAL_MS:正常节流(约 30fps)
|
|
360
|
+
* - 累积 ≥ MD_RENDER_BURST_CHARS:防单个超大 chunk 饿死。取 MD_RENDER_BURST_CHARS
|
|
361
|
+
* 远大于「一个间隔内正常 chunk 的累积量」是重点 —— 否则它会退化成「每 N chunk 渲一次」,
|
|
362
|
+
* 时间节流失效(实测取 64 时只降到 2x,因为高速流每 20ms 就攒够 64 字符)。
|
|
363
|
+
* 高速后端(约 5ms/chunk)一个 32ms 间隔约累积 6~7 个 token(~25 字符),故取 512:
|
|
364
|
+
* 既允许突发大段快速上屏,又不会在正常流里抢在时间节流前面触发。
|
|
365
|
+
*
|
|
366
|
+
* 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
|
|
367
|
+
* 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
|
|
368
|
+
* 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
|
|
369
|
+
*
|
|
370
|
+
* 收尾安全网:onToolCall / 轮末 / resize / 任何非 md 写都经 commitMd → flushMdSegment,
|
|
371
|
+
* 补渲定时器到点也会渲染,故不存在「最后一段丢失」。见 beginMdSegment/commitMd 的定时器清理。
|
|
372
|
+
*/
|
|
373
|
+
export function contentWriteMd(s) {
|
|
374
|
+
if (!state.active || !ui.isTTY) {
|
|
375
|
+
stdout.write(s);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!state.mdActive)
|
|
379
|
+
beginMdSegment();
|
|
380
|
+
state.mdBuf += s;
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
const elapsed = now - state.mdLastRenderAt;
|
|
383
|
+
if (shouldRenderMdSegment(elapsed, state.mdBuf.length - mdRenderedLen())) {
|
|
384
|
+
flushMdSegment();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// 尚未到渲染节奏:挂一个补渲定时器(若已有则不重复挂——同一段内始终只有一个)。
|
|
388
|
+
// unref:该定时器不该让进程在退出时多留一帧;退出路径由 exitAltScreen 清理。
|
|
389
|
+
if (!state.mdFlushTimer) {
|
|
390
|
+
state.mdFlushTimer = setTimeout(() => {
|
|
391
|
+
state.mdFlushTimer = null;
|
|
392
|
+
if (state.active && state.mdActive)
|
|
393
|
+
renderMdSegment();
|
|
394
|
+
}, MD_RENDER_INTERVAL_MS - elapsed);
|
|
395
|
+
state.mdFlushTimer.unref?.();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
284
398
|
/**
|
|
285
399
|
* 把一段完整(非流式)assistant 文本渲染进独立 markdown 段(供 renderHistory /resume /
|
|
286
400
|
* /rollback 回放)。与流式 contentWriteMd 的区别是一次完成 begin/set/commit,不累积 chunk;
|
|
@@ -321,6 +435,15 @@ export function clearContent() {
|
|
|
321
435
|
state.scrollLockUntil = 0;
|
|
322
436
|
state.mdActive = false;
|
|
323
437
|
state.mdBuf = '';
|
|
438
|
+
// 清 md 段状态必须同时撤掉待补渲定时器:否则它在 content.reset() 之后才到点触发,
|
|
439
|
+
// 往已清空的 buffer 里 setLines(残留 mdBuf 的旧内容) —— /clear、/resume、/theme
|
|
440
|
+
// 都会走到这里,表现为清屏后凭空冒出几行旧正文。
|
|
441
|
+
if (state.mdFlushTimer) {
|
|
442
|
+
clearTimeout(state.mdFlushTimer);
|
|
443
|
+
state.mdFlushTimer = null;
|
|
444
|
+
}
|
|
445
|
+
state.mdLastRenderAt = 0;
|
|
446
|
+
state.mdRenderedLen = 0;
|
|
324
447
|
// 清内容缓冲时必须同步重置 banner 状态:否则后续 writeBanner() 会走 rewriteBanner 路径,
|
|
325
448
|
// 在已空的 content 上调 replaceHead(0, lines) → startIdx(0) >= committed(0) → 抛错 → REPL 退出。
|
|
326
449
|
// /theme、/clear、/resume 等命令 clearContent 后紧接 writeBanner 的场景均依赖此重置。
|
|
@@ -465,6 +588,11 @@ export function dismissWelcomeBlock() {
|
|
|
465
588
|
export function contentInsertAfter(after, lines, keepViewport = true) {
|
|
466
589
|
if (!state.active || lines.length === 0)
|
|
467
590
|
return;
|
|
591
|
+
// 节流后 mdBuf 可能仍有未渲 chunk:调用方给的 after 是按**当前 buffer** 算的绝对行索引,
|
|
592
|
+
// 而待渲 chunk 还没进buffer。不先补渲就会插到错误位置(展开行插进正文中间)。
|
|
593
|
+
// 故凡有活跃 md 段,插入前先把累积内容渲上屏,让 buffer 与 mdBuf 对齐。
|
|
594
|
+
if (state.mdActive)
|
|
595
|
+
flushMdSegment();
|
|
468
596
|
const g = getGeo();
|
|
469
597
|
const totalBefore = content.totalRows();
|
|
470
598
|
const scrolled = state.scrollOffset > 0;
|
|
@@ -510,6 +638,9 @@ export function contentInsertAfter(after, lines, keepViewport = true) {
|
|
|
510
638
|
export function contentDeleteFrom(startIdx, n) {
|
|
511
639
|
if (!state.active || n <= 0)
|
|
512
640
|
return;
|
|
641
|
+
// 同 contentInsertAfter:startIdx 是按当前 buffer 算的,先补渲让两者对齐。
|
|
642
|
+
if (state.mdActive)
|
|
643
|
+
flushMdSegment();
|
|
513
644
|
const totalBefore = content.totalRows();
|
|
514
645
|
const scrolled = state.scrollOffset > 0;
|
|
515
646
|
content.deleteFrom(startIdx, n);
|
|
@@ -605,6 +736,12 @@ function remapLineForReflow(line, change) {
|
|
|
605
736
|
}
|
|
606
737
|
/** 终端尺寸变化后更新正文布局。列宽变化时先重排欢迎块、再重排 markdown 段;仅高度变化时只重算屏幕锚点。 */
|
|
607
738
|
export function reflowContentForResize(cols, colsChanged) {
|
|
739
|
+
// 节流后 mdBuf 可能仍有未渲的 chunk:reflowMarkdown 拿 segment.source(上次 setLines 时
|
|
740
|
+
// 的 source)按新列宽重排,若 source 滞后于 mdBuf,重排的是旧文本,而紧接着的一次
|
|
741
|
+
// 补渲又会把新文本按新列宽整段渲一遍 —— 中间这拍白做,更糟的是 oldLines/newLines 的
|
|
742
|
+
// 选区映射基于旧文本,用户选中的位置会漂。先补渲,让 reflow 看到的 source 与 mdBuf 一致。
|
|
743
|
+
if (state.mdActive)
|
|
744
|
+
flushMdSegment();
|
|
608
745
|
const oldTotal = content.totalRows();
|
|
609
746
|
const oldViewportStart = viewportAbsStart();
|
|
610
747
|
const oldViewportEnd = Math.max(oldViewportStart, oldTotal - state.scrollOffset - 1);
|
|
@@ -28,7 +28,7 @@ import { normalizeSelection, normalizeInputSelection, extractSelectionText, extr
|
|
|
28
28
|
import { composePlanLines, drawStatusBar, startTurnTimer, stopTurnTimer } from './statusbar.js';
|
|
29
29
|
import { paintInput, repaint } from './input-paint.js';
|
|
30
30
|
import { WHEEL_LINES, screenRowToAbsLine, repaintViewport, scrollBy, scrollWheel, resetScroll, lockScrollToBottom, } from './scroll.js';
|
|
31
|
-
import { contentWrite, contentInsertAfter, contentDeleteFrom, contentReplaceLine, reflowContentForResize, } from './content-write.js';
|
|
31
|
+
import { contentWrite, contentInsertAfter, contentDeleteFrom, contentReplaceLine, reflowContentForResize, flushMdSegment, } from './content-write.js';
|
|
32
32
|
export { isTuiActive, getGeo, setRegion, contentMode, isStreamingPaused } from './screen.js';
|
|
33
33
|
export { drawStatusBar, setStatus, stopTurnTimer, startTurnTimerIfRunning, paintLiveAtCursor, clearLiveAtCursor, setLiveUsage, setStatusBase, } from './statusbar.js';
|
|
34
34
|
export { paintInput, paintRunningInput, repaint } from './input-paint.js';
|
|
@@ -396,6 +396,12 @@ export function enterInputMode(status = t('repl.idle')) {
|
|
|
396
396
|
if (state.active)
|
|
397
397
|
repaintViewport();
|
|
398
398
|
}
|
|
399
|
+
// 轮末补渲流式 md:节流后 mdBuf 可能仍有未渲的 chunk(下一次渲染的定时器还没到点),
|
|
400
|
+
// 而下面要画输入框、repaintViewport —— 画面会停在上一帧,看起来像「回复最后几句丢了」。
|
|
401
|
+
// 正常轮末会先经 commitMd(有 flush)走到这里,mdActive 应已是 false;此分支只兜
|
|
402
|
+
// 「异常路径直接进输入态」的情况,故只在 mdActive 时才动手。
|
|
403
|
+
if (state.active && state.mdActive)
|
|
404
|
+
flushMdSegment();
|
|
399
405
|
if (state.active && state.base) {
|
|
400
406
|
// 先按当前 base.planSummary 重算 planRows(可能从上次会话残留 stale 值),再据此 setRegion
|
|
401
407
|
// 撑出正确脚栏高;否则 plan 撑 2 行时 setRegion(6) 会把 spinner 挤到 plan 第 2 行位置。
|
|
@@ -462,6 +468,12 @@ export function enterAltScreen() {
|
|
|
462
468
|
state.scrollLockUntil = 0;
|
|
463
469
|
state.mdActive = false;
|
|
464
470
|
state.mdBuf = '';
|
|
471
|
+
if (state.mdFlushTimer) {
|
|
472
|
+
clearTimeout(state.mdFlushTimer);
|
|
473
|
+
state.mdFlushTimer = null;
|
|
474
|
+
}
|
|
475
|
+
state.mdLastRenderAt = 0;
|
|
476
|
+
state.mdRenderedLen = 0;
|
|
465
477
|
state.selection = null;
|
|
466
478
|
state.selecting = false;
|
|
467
479
|
content.reset();
|
|
@@ -570,6 +582,11 @@ export function exitAltScreen() {
|
|
|
570
582
|
clearTimeout(state.flushTimer);
|
|
571
583
|
state.flushTimer = null;
|
|
572
584
|
}
|
|
585
|
+
// 流式 md 补渲定时器同理:退出后再触发会往已退 alt screen 的终端写内容。
|
|
586
|
+
if (state.mdFlushTimer) {
|
|
587
|
+
clearTimeout(state.mdFlushTimer);
|
|
588
|
+
state.mdFlushTimer = null;
|
|
589
|
+
}
|
|
573
590
|
state.userActiveUntil = 0;
|
|
574
591
|
}
|
|
575
592
|
/**
|
|
@@ -25,11 +25,23 @@ export const state = {
|
|
|
25
25
|
contentRow: 1, // 续写位行(1-based,屏坐标,[1,contentBottom])
|
|
26
26
|
contentCol: 1, // 续写位列(1-based)
|
|
27
27
|
segmentStartRow: 1, // 当前 md 段起始屏行(供 contentWriteMd 定位段末续写位;段内行数由 content 段标记跟踪)
|
|
28
|
-
/** markdown 流式段:agent onText 的 chunk 累积到 mdBuf
|
|
28
|
+
/** markdown 流式段:agent onText 的 chunk 累积到 mdBuf,按节流节奏经renderMarkdown
|
|
29
29
|
* 渲成自洽行,replace 缓冲段(content.setLines)+ repaintViewport 重画。mdActive 期间任何
|
|
30
|
-
* 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
|
|
30
|
+
* 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
|
|
31
|
+
*
|
|
32
|
+
* **为什么需要节流**(2026-10-05 实测):每 chunk 重渲整个累积 mdBuf 是 O(n²)——
|
|
33
|
+
* 一次 5879 字符回复被切成~1960 chunk,累计扫描 5.5MB、纯 CPU ~900ms。renderMarkdown
|
|
34
|
+
* 内部的 LRU memo(key=text)在流式下每次 text 都变,必然 miss,救不了。真正的成本是
|
|
35
|
+
* **次数**而非单次(实测单次 render 仅 0.05ms 量级)。故改为「攒够一段时间或跨过行
|
|
36
|
+
* 边界才渲」,把渲染次数从 O(chunk数) 降到 O(耗时/节流间隔)。 */
|
|
31
37
|
mdActive: false,
|
|
32
38
|
mdBuf: '',
|
|
39
|
+
/** 流式 md 上次实际渲染的时刻(Date.now());0=本段尚未渲过。节流判定用。 */
|
|
40
|
+
mdLastRenderAt: 0,
|
|
41
|
+
/** 本段已渲进缓冲的 mdBuf 长度;内容节流判定「攒够多少」用。 */
|
|
42
|
+
mdRenderedLen: 0,
|
|
43
|
+
/** 节流到期的定时器;非 null 表示「还有未渲染的 chunk 待补渲」。 */
|
|
44
|
+
mdFlushTimer: null,
|
|
33
45
|
bannerH: 0,
|
|
34
46
|
welcomeStart: -1, // 块起点(content.committedRows 口径的绝对行索引)
|
|
35
47
|
welcomeRows: 0, // 块行数(0 = 屏上无欢迎块)
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { discoverProjectProfile } from './profile.js';
|
|
4
4
|
const COMPATIBILITY_PRIORITY = ['typecheck', 'test', 'build'];
|
|
5
|
-
const LAYERED_ORDER = ['typecheck', 'build', 'test'];
|
|
5
|
+
const LAYERED_ORDER = ['typecheck', 'lint', 'build', 'test'];
|
|
6
6
|
const isWindows = process.platform === 'win32';
|
|
7
7
|
function samePath(left, right) {
|
|
8
8
|
const normalizedLeft = path.resolve(left);
|
|
@@ -18,7 +18,7 @@ function lineFor(profile, packageProfile) {
|
|
|
18
18
|
/**
|
|
19
19
|
* Deterministic project validation map injected into the system prompt: which package owns which
|
|
20
20
|
* script, and the exact command plus cwd to run it. Commands are listed in increasing cost order
|
|
21
|
-
* (typecheck → build → test) and are never executed here — this is evidence, not a completion gate.
|
|
21
|
+
* (typecheck → lint → build → test) and are never executed here — this is evidence, not a completion gate.
|
|
22
22
|
*
|
|
23
23
|
* Returns '' when no package exposes a validation script, or when discovery fails for any reason
|
|
24
24
|
* (missing/invalid manifest, unreadable workspace): prompt construction must never break.
|