mocode-ai 1.3.9 → 1.4.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.
Files changed (134) hide show
  1. package/README.md +78 -70
  2. package/README.zh-CN.md +81 -73
  3. package/bin/mocode-agent-host.js +1 -1
  4. package/dist/agent/core.js +226 -293
  5. package/dist/agent/index.js +14 -10
  6. package/dist/agent/mode.js +3 -3
  7. package/dist/agent/runtime-context.js +45 -0
  8. package/dist/agent/spawn.js +101 -86
  9. package/dist/agent/tool-helpers.js +134 -0
  10. package/dist/agent/trace-state.js +118 -0
  11. package/dist/agents/coordinator.js +6 -1
  12. package/dist/changeset/index.js +9 -8
  13. package/dist/config/index.js +109 -77
  14. package/dist/config/presets.js +3 -1
  15. package/dist/config/profiles.js +125 -0
  16. package/dist/context/age-aware.js +2 -4
  17. package/dist/context/artifacts.js +6 -13
  18. package/dist/context/budget.js +6 -8
  19. package/dist/context/classifier.js +1 -2
  20. package/dist/context/encoders/code.js +2 -6
  21. package/dist/context/encoders/command.js +1 -3
  22. package/dist/context/encoders/search.js +1 -3
  23. package/dist/context/index.js +1 -1
  24. package/dist/context/lifecycle.js +5 -3
  25. package/dist/context/pipeline.js +1 -1
  26. package/dist/context/relevance.js +2 -4
  27. package/dist/context/token-calibration.js +13 -18
  28. package/dist/host/stdio.js +73 -12
  29. package/dist/i18n/index.js +96 -44
  30. package/dist/index.js +1 -1
  31. package/dist/llm/index.js +39 -23
  32. package/dist/llm/provider.js +23 -0
  33. package/dist/llm/providers/anthropic.js +12 -18
  34. package/dist/llm/tool-schema.js +48 -0
  35. package/dist/mcp/client.js +70 -25
  36. package/dist/mcp/config.js +1 -3
  37. package/dist/mcp/index.js +3 -1
  38. package/dist/memory/reflect.js +3 -6
  39. package/dist/memory/store.js +10 -12
  40. package/dist/permissions/index.js +66 -22
  41. package/dist/pet/bridge.js +2 -2
  42. package/dist/repl/commands/appearance.js +72 -0
  43. package/dist/repl/commands/compact.js +114 -0
  44. package/dist/repl/commands/context.js +17 -0
  45. package/dist/repl/commands/image.js +71 -0
  46. package/dist/repl/commands/memory.js +51 -0
  47. package/dist/repl/commands/mode.js +47 -0
  48. package/dist/repl/commands/model.js +435 -0
  49. package/dist/repl/commands/pet.js +70 -0
  50. package/dist/repl/commands/registry.js +46 -0
  51. package/dist/repl/commands/session.js +102 -0
  52. package/dist/repl/commands/skill.js +95 -0
  53. package/dist/repl/commands/system.js +202 -0
  54. package/dist/repl/commands/tool-group.js +233 -0
  55. package/dist/repl/commands/types.js +4 -0
  56. package/dist/repl/commands.js +324 -0
  57. package/dist/repl/index.js +5 -2577
  58. package/dist/repl/message-format.js +151 -0
  59. package/dist/repl/running-input.js +147 -0
  60. package/dist/repl/runtime.js +816 -0
  61. package/dist/repl/status-bar.js +161 -0
  62. package/dist/rollback/index.js +44 -14
  63. package/dist/runtime/browser-manager.js +1 -1
  64. package/dist/runtime/dev-server-manager.js +1 -1
  65. package/dist/runtime/input-injector.js +313 -0
  66. package/dist/runtime/screen-capture.js +89 -0
  67. package/dist/runtime/screen-pipeline.js +223 -0
  68. package/dist/sandbox/index.js +1 -1
  69. package/dist/sandbox/policy.js +8 -2
  70. package/dist/session/compact.js +22 -34
  71. package/dist/session/index.js +2 -2
  72. package/dist/session/notes.js +39 -5
  73. package/dist/session/persist.js +14 -8
  74. package/dist/session/scheduler.js +3 -4
  75. package/dist/session/trace-metrics.js +6 -3
  76. package/dist/skills/activation.js +10 -5
  77. package/dist/skills/discover.js +5 -17
  78. package/dist/skills/index.js +1 -1
  79. package/dist/skills/runner.js +28 -44
  80. package/dist/skills/toolmap.js +5 -2
  81. package/dist/tools/builtins/browser.js +17 -3
  82. package/dist/tools/builtins/computer.js +322 -0
  83. package/dist/tools/builtins/dev-server.js +1 -3
  84. package/dist/tools/builtins/edit-file.js +21 -7
  85. package/dist/tools/builtins/grep.js +1 -1
  86. package/dist/tools/builtins/index.js +28 -25
  87. package/dist/tools/builtins/memory-forget.js +1 -1
  88. package/dist/tools/builtins/memory-graph.js +4 -7
  89. package/dist/tools/builtins/memory-list.js +2 -6
  90. package/dist/tools/builtins/memory-save.js +8 -2
  91. package/dist/tools/builtins/memory-search.js +4 -4
  92. package/dist/tools/builtins/note-append.js +14 -3
  93. package/dist/tools/builtins/plan-update.js +40 -9
  94. package/dist/tools/builtins/run-command.js +8 -8
  95. package/dist/tools/builtins/screenshot.js +11 -87
  96. package/dist/tools/builtins/task.js +29 -40
  97. package/dist/tools/builtins/use-skill.js +1 -1
  98. package/dist/tools/builtins/view-image.js +5 -3
  99. package/dist/tools/builtins/web-fetch.js +1 -3
  100. package/dist/tools/builtins/web-search.js +3 -7
  101. package/dist/tools/builtins/write-file.js +4 -2
  102. package/dist/tools/constants.js +50 -37
  103. package/dist/tools/policy.js +221 -0
  104. package/dist/tools/registry.js +40 -19
  105. package/dist/tools/resource-lock.js +4 -2
  106. package/dist/tools/router.js +143 -0
  107. package/dist/tools/validation.js +6 -5
  108. package/dist/ui/batch.js +63 -23
  109. package/dist/ui/clipboard.js +17 -5
  110. package/dist/ui/content.js +166 -13
  111. package/dist/ui/diff.js +61 -19
  112. package/dist/ui/fuzzy.js +35 -2
  113. package/dist/ui/intervention.js +2 -4
  114. package/dist/ui/layout-internal/content-write.js +587 -0
  115. package/dist/ui/layout-internal/core.js +590 -0
  116. package/dist/ui/layout-internal/input-paint.js +365 -0
  117. package/dist/ui/layout-internal/screen.js +128 -0
  118. package/dist/ui/layout-internal/scroll.js +152 -0
  119. package/dist/ui/layout-internal/selection.js +229 -0
  120. package/dist/ui/layout-internal/state.js +82 -0
  121. package/dist/ui/layout-internal/statusbar.js +351 -0
  122. package/dist/ui/layout-types.js +1 -0
  123. package/dist/ui/layout.js +3 -2288
  124. package/dist/ui/markdown.js +10 -2
  125. package/dist/ui/prompt-internal/editor.js +945 -0
  126. package/dist/ui/prompt-internal/paste.js +52 -0
  127. package/dist/ui/prompt-internal/pickers.js +519 -0
  128. package/dist/ui/prompt-internal/types.js +1 -0
  129. package/dist/ui/prompt.js +4 -1506
  130. package/dist/ui/render.js +87 -2
  131. package/dist/verification/affected.js +11 -5
  132. package/dist/verification/discovery.js +1 -3
  133. package/dist/verification/profile.js +11 -3
  134. package/package.json +11 -2
@@ -0,0 +1,587 @@
1
+ // L3 内容写入:续写位状态机 / markdown 流式段 / banner / 欢迎块 / resize 重排。
2
+ // 从 layout-internal/core.ts 按依赖分层拆出;可变运行态集中在 ./state.ts。
3
+ import { stdout } from 'node:process';
4
+ import { charWidth, truncateAnsi, ansiDisplayWidth, remapWrappedPoint } from '../render.js';
5
+ import { ui } from '../theme.js';
6
+ import * as content from '../content.js';
7
+ import { reset as resetBatches, shiftBatchesAfter, shiftBatchesForReflow } from '../batch.js';
8
+ import { renderMarkdown } from '../markdown.js';
9
+ import { state } from './state.js';
10
+ import { esc, cup, getGeo, setRegion, runningCaretPos } from './screen.js';
11
+ import { viewportAbsStart, repaintViewport } from './scroll.js';
12
+ /**
13
+ * 内容区正文写:CUP 到续写位 + 写出 + 按字符宽度/折行更新续写位 + 同步入 content 缓冲(供滚动回看)。
14
+ * 滚动区域内写满自动在区域内滚动,底栏在区域外不被顶。非 TTY / 未激活:直接 stdout.write(内联退化)。
15
+ *
16
+ * 逐字符分类(按码点取字符,SGR 序列整段识别):SGR 码 → 0 宽跟踪 + content.feedSgr;其他 CSI/OSC → 跳过
17
+ * (callers 理论上不传);\n / 折行 / tab 换行 → 行推进 + content.breakRow;可见字符 → charWidth 跟踪 +
18
+ * content.feedChar。SGR 在终端 0 宽,故续写位只按可见字符推进(与终端实际光标一致)。
19
+ */
20
+ export function contentWrite(s) {
21
+ if (!state.active || !ui.isTTY) {
22
+ stdout.write(s);
23
+ return;
24
+ }
25
+ if (state.mdActive)
26
+ commitMd(); // 非 md 写接续:先收尾 md 段(清 segMark,否则 setLines 会截到旧段)
27
+ // 滚动回看时(scrollOffset>0)只喂缓冲 + 推进续写位,不物理写——否则新流式内容覆盖 viewport 历史行。
28
+ // 回尾(scrollOffset===0)才物理写;写起点 = 当前续写位(loop 会推进续写位,故先捕获)。
29
+ const g = getGeo();
30
+ const cols = g.cols;
31
+ const bottom = g.contentBottom;
32
+ // resize 缩窄后,当前待写行可能已占满或超过新列宽。必须在捕获物理写起点前
33
+ // 先提交该行并移到下一行;否则 CUP 会把 cols+1 钳到末列,下一字符覆盖旧行尾。
34
+ if (state.contentCol > cols) {
35
+ content.breakRow();
36
+ state.contentRow = state.contentRow >= bottom ? bottom : state.contentRow + 1;
37
+ state.contentCol = 1;
38
+ }
39
+ // resize 后 contentRow 可能过时(拖终端框):
40
+ // - 缩小:contentRow > 新 bottom → 钳到新 bottom
41
+ // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(committed+1, bottom)
42
+ // committed+1 是合法「待写位」(所有行已 breakRow 提交、光标在新空行);用 committed 会把续写位拉回到
43
+ // 最后一行 banner/content 上,首次 contentWrite 覆盖 banner(首条消息「插到 logo 下面」bug)。
44
+ // 注意:不能用 totalRows()+1——breakRow 后 hasCurrent=true,totalRows 已含当前空行,再 +1 会多跳一行,
45
+ // 导致 onDone 摘要行等写 \n 后的 contentWrite 入口多跳 1 行(2 空行 bug)。
46
+ if (state.contentRow > bottom)
47
+ state.contentRow = bottom;
48
+ else if (state.scrollOffset === 0 && state.contentRow < bottom) {
49
+ state.contentRow = Math.min(content.committedRows() + 1, bottom);
50
+ }
51
+ const startRow = state.contentRow;
52
+ const startCol = state.contentCol;
53
+ const advanceRow = (r) => (r >= bottom ? bottom : r + 1); // 到底则滚动,续写位留底
54
+ // 滚动回看冻结(仿 ink store.pushBlock 的 offset+=newLines):scrollOffset>0 时新内容只入缓冲,
55
+ // 但缓冲增长后若 offset 不变,下次 scrollBy→repaintViewport 会取漂移窗口、把新内容画进历史视图
56
+ // (「工具消息跳到上面覆盖」bug;滚回底又正常,因 buffer 正确)。故入缓冲前后算 totalRows 差,
57
+ // offset += 差以冻住视图(窗口停原绝对行,无需 repaint;滚回底自显尾部新内容)。
58
+ const scrolled = state.scrollOffset > 0;
59
+ const totalBefore = scrolled ? content.totalRows() : 0;
60
+ let i = 0;
61
+ while (i < s.length) {
62
+ const rest = s.slice(i);
63
+ // SGR 序列 \x1B[...m:0 宽 + 喂缓冲
64
+ const sgr = /^\x1b\[[0-9;]*m/.exec(rest);
65
+ if (sgr) {
66
+ content.feedSgr(sgr[0]);
67
+ i += sgr[0].length;
68
+ continue;
69
+ }
70
+ // 其他 CSI / OSC 序列(兜底跳过,不喂缓冲——callers 不该传控制码)
71
+ if (s[i] === '\x1b') {
72
+ const csi = /^\x1b\[[0-9;]*[A-Za-z]/.exec(rest);
73
+ if (csi) {
74
+ i += csi[0].length;
75
+ continue;
76
+ }
77
+ const osc = /^\x1b\][^\x07]*(\x07|\x1b\\)/.exec(rest);
78
+ if (osc) {
79
+ i += osc[0].length;
80
+ continue;
81
+ }
82
+ i += 1;
83
+ continue;
84
+ }
85
+ const cp = s.codePointAt(i) ?? 0;
86
+ const ch = String.fromCodePoint(cp);
87
+ if (ch === '\n') {
88
+ state.contentRow = advanceRow(state.contentRow);
89
+ content.breakRow();
90
+ state.contentCol = 1;
91
+ i += ch.length;
92
+ continue;
93
+ }
94
+ if (ch === '\r') {
95
+ state.contentCol = 1;
96
+ i += ch.length;
97
+ continue;
98
+ }
99
+ if (ch === '\t') {
100
+ const next = Math.floor((state.contentCol - 1) / 8) * 8 + 8 + 1;
101
+ if (next > cols) {
102
+ state.contentRow = advanceRow(state.contentRow);
103
+ content.breakRow();
104
+ state.contentCol = 1;
105
+ }
106
+ else
107
+ state.contentCol = next;
108
+ i += ch.length;
109
+ continue;
110
+ }
111
+ const cw = charWidth(cp);
112
+ if (cw === 0) {
113
+ i += ch.length;
114
+ continue; // 组合符 / 零宽:不推进
115
+ }
116
+ if (state.contentCol + cw - 1 > cols) {
117
+ // 当前行放不下:折行
118
+ state.contentRow = advanceRow(state.contentRow);
119
+ content.breakRow();
120
+ state.contentCol = 1;
121
+ }
122
+ state.contentCol += cw;
123
+ content.feedChar(ch);
124
+ i += ch.length;
125
+ }
126
+ // 跨调用 pending-wrap 提交:循环末若 contentCol>cols(末字符恰好落在最后一列,终端 pending-wrap),
127
+ // 必须在此提交换行——否则下一 chunk 的 cup(startRow, cols+1) 被终端 clamp 到 col=cols 且 CUP 清除
128
+ // pending,下一可打印字符会覆盖上一行末字符(「新输出插到上文中间」bug);paintLiveAtCursor 也会
129
+ // cup 到 cols+1 并 \x1B[2K 擦掉整行。提交后模拟与终端都停在 (下一行,1)、无 pending,等价 ink 的
130
+ // 「新输出永远在底」。代价:chunk 恰在末列结束且下一 chunk 以 \n 开头时多一个空行(cosmetic,远好于覆盖)。
131
+ // 模拟归一化在物理写 guard 之外(滚动态也要保 buffer 自洽);物理补的 \n 在 guard 之内(滚动态/暂停态不物理写)。
132
+ const pendingWrap = state.contentCol > cols;
133
+ if (pendingWrap) {
134
+ content.breakRow();
135
+ state.contentRow = advanceRow(state.contentRow);
136
+ state.contentCol = 1;
137
+ }
138
+ // 滚动回看冻结:新内容入缓冲后 offset += delta(contentWrite 只增故 ≥0;md 路径可负),钳到 [0, maxOff],
139
+ // viewport 窗口停原绝对行(不漂移到新工具消息)。无需 repaint——窗口未变(屏仍显原历史)。
140
+ if (scrolled) {
141
+ const delta = content.totalRows() - totalBefore;
142
+ if (delta !== 0) {
143
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
144
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset + delta, maxOff));
145
+ }
146
+ }
147
+ // 物理写(回尾 offset=0):cup 写起点 + s + (pending 时补 \n 提交换行)+ (运行态 cup 回输入框)。
148
+ // 打字中不再暂停物理写——单次 write 结尾 cup 回 runningCaretPos(输入框),IME 锚定不动(跟踪每次 write
149
+ // 最终位置);旧设计 isStreamingPaused 暂停写致流式卡顿,现单次写归位已无需暂停。
150
+ if (state.scrollOffset === 0) {
151
+ let out = cup(startRow, startCol) + s;
152
+ if (pendingWrap)
153
+ out += '\n'; // 滚动区域底行触发 DECSTBM 上滚、中段 LF 下移到 (下一行,1)
154
+ if (state.mode === 'running') {
155
+ const p = runningCaretPos();
156
+ out += cup(p.row, p.col);
157
+ }
158
+ stdout.write(out);
159
+ }
160
+ }
161
+ /** 进入 markdown 流式段:标记段起点(layout 续写位 + content.beginSegment),清 accumulator。 */
162
+ function beginMdSegment() {
163
+ state.segmentStartRow = state.contentRow;
164
+ content.beginSegment();
165
+ state.mdBuf = '';
166
+ state.mdActive = true;
167
+ }
168
+ /** 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。 */
169
+ function commitMd() {
170
+ if (!state.mdActive)
171
+ return;
172
+ state.mdActive = false;
173
+ state.mdBuf = '';
174
+ content.commitSegment();
175
+ }
176
+ // ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
177
+ // 与 contentWrite 的「续写式追加」不同:banner 自带模式行集合(MoCode logo + 信息行),
178
+ // 反复 writeBanner(同一启动会话内同一份 banner 反复画 — 启动后、换 model 后、刷新 snapshot 后)
179
+ // 应让 buffer 顶部始终是「最新的一份 banner」,而不是堆 5 份。
180
+ // rewriteBanner 在 banner 已建好时原地等长替换顶部 bannerH 行,viewport 自动从 rows[] 头读新版。
181
+ //
182
+ // bannerH = bannerLines(banner()) 的行数(目前 5:4 行 logo+info + 1 空行分隔)。如未来
183
+ // bannerString 变化,调用方需重调 writeBanner 重设 bannerH。
184
+ /**
185
+ * 在 content 缓冲顶部建一份 banner;**首次**调用建好(bannerH = lines.length),**重复**调用
186
+ * 等价 rewriteBanner(lines)(覆盖式刷新,供启动后再写 banner 也安全)。
187
+ *
188
+ * lines 必须与首次写入等长(否则 content.replaceHead 抛错)— 调用方应统一传 bannerLines(banner())。
189
+ * 滚动回看冻结(scrollOffset+=delta / 改 offset 不变):行数不变 offset 不动 → 视图冻结,无须 repaint 风暴。
190
+ * 仅当 scrollOffset === 0 时 repaintViewport 让新版 banner 立刻进 viewport 顶部。
191
+ */
192
+ export function writeBanner(lines) {
193
+ if (state.bannerH === 0) {
194
+ // 首次:buffer 通常空(启动最早时),用 setLines 灌入;若 buffer 非空(如 reseed)说明 banner
195
+ // 已被前置写过,throw 阻止静默错位 — 调用方应先 reset banner 然后 writeBanner。
196
+ if (state.mdActive)
197
+ commitMd();
198
+ const existed = content.totalRows();
199
+ if (existed !== 0) {
200
+ throw new Error(`writeBanner: 首次调用时 buffer 已有 ${existed} 行,需先 clearContent 再 writeBanner(否则会覆盖已有内容)`);
201
+ }
202
+ content.setLines(lines);
203
+ state.bannerH = lines.length;
204
+ // 续写位推进到 banner 之后:clearContent 把 contentRow 归到 1,writeBanner 灌入 bannerH 行
205
+ // 但不动 contentRow → 首次 contentWrite 会从屏行 1 写出(覆盖 banner)。这里同步修正,
206
+ // 让首条用户消息从 banner 下方开始(与 buffer 尾部对齐)。仅首次走这条,后续 rewriteBanner 不动续写位。
207
+ state.contentRow = Math.min(state.bannerH + 1, getGeo().contentBottom);
208
+ state.contentCol = 1;
209
+ if (state.scrollOffset === 0)
210
+ repaintViewport();
211
+ return;
212
+ }
213
+ // 后续:等价 rewriteBanner
214
+ rewriteBanner(lines);
215
+ }
216
+ /**
217
+ * 把 banner 顶部 bannerH 行**等长替换**为新行;banner 后的内容(对话历史)位置不动。
218
+ * 行数与首次 writeBanner 不一致 → 抛错(避免 banner 区错位);如必须改,先清空 content 后再 writeBanner。
219
+ * 滚动回看冻结:行数不变,offset 不动。等 scrollOffset 回到 0 时 viewport 自动显新版 banner。
220
+ */
221
+ export function rewriteBanner(lines) {
222
+ if (state.bannerH === 0) {
223
+ // 没建过 banner:等价首次 writeBanner(允许首次调用直接进 rewriteBanner 的别名入口)
224
+ writeBanner(lines);
225
+ return;
226
+ }
227
+ if (lines.length !== state.bannerH) {
228
+ throw new Error(`rewriteBanner: 行数 ${lines.length} ≠ 已建 bannerH ${state.bannerH}(调用方需统一行数)`);
229
+ }
230
+ content.replaceHead(0, lines);
231
+ if (state.scrollOffset === 0)
232
+ repaintViewport();
233
+ }
234
+ /** 测试 / 调试用:当前 banner 占多少行;0 表示未启用。 */
235
+ export function bannerHeight() {
236
+ return state.bannerH;
237
+ }
238
+ /**
239
+ * markdown 正文写(替代 contentWrite 用于 agent onText):累积 chunk 到 mdBuf,每 chunk 把整段
240
+ * mdBuf 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines 截旧 + 写新),
241
+ * repaintViewport 重画(仅尾 offset=0 且未暂停)。流式安全:未闭合 fence 照常 emit 进行中代码块;
242
+ * renderMarkdown 内部 memo by text 使重复渲染命中缓存。非 TTY / 未激活:直接 stdout.write(与
243
+ * contentWrite 一致,管道流式可见)。
244
+ *
245
+ * 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
246
+ * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
247
+ * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
248
+ */
249
+ export function contentWriteMd(s) {
250
+ if (!state.active || !ui.isTTY) {
251
+ stdout.write(s);
252
+ return;
253
+ }
254
+ if (!state.mdActive)
255
+ beginMdSegment();
256
+ state.mdBuf += s;
257
+ const g = getGeo();
258
+ // 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
259
+ // 下次 scrollBy→repaintViewport 取漂移窗口、把流式正文画进历史视图。故 setLines 前后算 totalRows 差,
260
+ // offset += delta(可负:setLines 重渲染可能缩行)冻住视图。md 段在尾,窗口在上方,冻结后不重叠。
261
+ const scrolled = state.scrollOffset > 0;
262
+ const totalBefore = scrolled ? content.totalRows() : 0;
263
+ const lines = renderMarkdown(state.mdBuf, g.cols);
264
+ content.setLines(lines, state.mdBuf);
265
+ const segRows = lines.length;
266
+ const available = g.contentBottom - state.segmentStartRow + 1;
267
+ state.contentRow = segRows >= available ? g.contentBottom : state.segmentStartRow + segRows;
268
+ state.contentCol = 1;
269
+ if (scrolled) {
270
+ const delta = content.totalRows() - totalBefore;
271
+ if (delta !== 0) {
272
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
273
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset + delta, maxOff));
274
+ }
275
+ }
276
+ if (state.scrollOffset === 0) {
277
+ repaintViewport(); // 单次 write 结尾 cup 回 runningCaretPos(运行态),IME 锚输入框;打字中不再暂停
278
+ if (state.mode === 'running') {
279
+ const p = runningCaretPos();
280
+ stdout.write(cup(p.row, p.col));
281
+ }
282
+ }
283
+ }
284
+ /**
285
+ * 把一段完整(非流式)assistant 文本渲染进独立 markdown 段(供 renderHistory /resume /
286
+ * /rollback 回放)。与流式 contentWriteMd 的区别是一次完成 begin/set/commit,不累积 chunk;
287
+ * 但同样保留原始 markdown source,因此窗口列宽变化后历史正文也能重新排版。
288
+ */
289
+ export function contentWriteMdOnce(s) {
290
+ if (!state.active || !ui.isTTY) {
291
+ stdout.write(s);
292
+ return;
293
+ }
294
+ if (state.mdActive)
295
+ commitMd();
296
+ const g = getGeo();
297
+ content.beginSegment();
298
+ const lines = renderMarkdown(s, g.cols);
299
+ content.setLines(lines, s);
300
+ content.commitSegment();
301
+ // setLines 直接提交了全部 markdown 物理行;补回一个未提交的空当前行,保证满屏时
302
+ // 下一次 contentWrite 从正文下一行开始,而不是在 contentBottom 第 1 列覆盖正文末行。
303
+ content.ensureCurrentRow();
304
+ state.contentRow = Math.min(content.committedRows() + 1, g.contentBottom);
305
+ state.contentCol = 1;
306
+ if (state.scrollOffset === 0)
307
+ repaintViewport();
308
+ }
309
+ /** 清空内容区(保留底栏):全屏清 + 重设区域 + 续写位归 (1,1) + 清缓冲 + 回尾。底栏由调用方随后重画。 */
310
+ export function clearContent() {
311
+ if (!state.active)
312
+ return;
313
+ stdout.write('\x1B[1;1H\x1B[2J');
314
+ setRegion(state.footerH);
315
+ state.contentRow = 1;
316
+ state.contentCol = 1;
317
+ state.frameRow = 0;
318
+ state.frameCol = 0;
319
+ state.segmentStartRow = 1;
320
+ state.scrollOffset = 0;
321
+ state.scrollLockUntil = 0;
322
+ state.mdActive = false;
323
+ state.mdBuf = '';
324
+ // 清内容缓冲时必须同步重置 banner 状态:否则后续 writeBanner() 会走 rewriteBanner 路径,
325
+ // 在已空的 content 上调 replaceHead(0, lines) → startIdx(0) >= committed(0) → 抛错 → REPL 退出。
326
+ // /theme、/clear、/resume 等命令 clearContent 后紧接 writeBanner 的场景均依赖此重置。
327
+ state.bannerH = 0;
328
+ // 欢迎引导块随 buffer 一起清掉(/clear / /resume 等路径),状态复位后可由 repl 重新写入。
329
+ state.welcomeStart = -1;
330
+ state.welcomeRows = 0;
331
+ // 清内容区必须同时作废旧菜单擦除坐标:picker(/resume /rollback /theme)把菜单画在内容区底部,
332
+ // 菜单行号缓存在 lastMenuStartRow/lastMenuRows;若不清零,后续 paintInput 会按旧坐标“擦菜单”,
333
+ // 把刚 renderHistory/contentWrite 写好的内容(如“已续接会话”提示)清掉,导致用户要滚动一下才刷新。
334
+ state.lastMenuStartRow = 0;
335
+ state.lastMenuRows = 0;
336
+ content.reset();
337
+ notifyContentReset(); // batch 渲染器同步重置(batch 摘要行索引全部失效)
338
+ stdout.write(esc.home);
339
+ }
340
+ /**
341
+ * 撤销(rewind)内容区末尾 n 物理行 —— 供 "刚 echoInput 写下用户气泡、用户撤回"
342
+ * 把气泡从可视区与 buffer 一起拿掉。content.rewind 弹出 buffer 末 n 行;layout 这边
343
+ * 同步上溯 contentRow(contentTop 钳位,防下溢)、scrollOffset 钳到新 totalRows、
344
+ * 最后 repaintViewport 重画可视区(running 态回 runningCaretPos,input 态回续写位)。
345
+ *
346
+ * 与 clearContent 不同:只擦最近一段、保留更早内容与 buffer;光标归内容区底,
347
+ * 不是 (1,1)。常发生在 RUNNING 态的 pending 窗口内 recall —— recall 后调用方
348
+ * 通常再 enterInputMode 切回 INPUT 视觉,光标由后续 paintInput 重画。
349
+ */
350
+ export function rewindContent(rowsToRewind) {
351
+ if (!state.active || rowsToRewind <= 0)
352
+ return;
353
+ content.rewind(rowsToRewind);
354
+ const g = getGeo();
355
+ state.contentRow = Math.max(g.contentTop, state.contentRow - rowsToRewind);
356
+ state.contentCol = 1;
357
+ // 钳 scrollOffset:不能让 viewport 视点超出新 totalRows
358
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
359
+ state.scrollOffset = Math.min(state.scrollOffset, maxOff);
360
+ // 末段 frameRow/frameCol 是 spinner 的画位,撤回若跨过 frame 行也不必清——
361
+ // repaintViewport 会按新 buffer 重画整片,旧 frame 自然被覆盖。
362
+ repaintViewport();
363
+ }
364
+ // ── 欢迎引导块(新会话开场)──
365
+ // 开场写在内容区(banner 之下),教用户怎么开始;首次提交任何输入(消息或斜杠命令)前
366
+ // 由 dismissWelcomeBlock 整块从 buffer 撤掉——「一打开就能看见,开始干活就消失」。
367
+ /** 写欢迎引导块(每行自洽带色、行宽须 ≤ cols,contentWrite 状态机兜底折行);已在屏上则跳过。 */
368
+ export function writeWelcomeBlock(lines) {
369
+ if (!state.active || !ui.isTTY || lines.length === 0)
370
+ return;
371
+ if (state.welcomeRows > 0)
372
+ return; // 已在屏上,不重复写
373
+ state.welcomeStart = content.committedRows();
374
+ contentWrite(lines.join('\n') + '\n');
375
+ state.welcomeRows = content.committedRows() - state.welcomeStart;
376
+ }
377
+ /** 撤掉欢迎引导块:删 buffer 区间(若已被外部清空/裁掉则只复位状态)+ 续写位前移 + 钳位重画。 */
378
+ export function dismissWelcomeBlock() {
379
+ if (state.welcomeRows <= 0)
380
+ return;
381
+ const start = state.welcomeStart;
382
+ const n = state.welcomeRows;
383
+ state.welcomeRows = 0;
384
+ state.welcomeStart = -1;
385
+ if (start < 0 || start >= content.committedRows())
386
+ return; // 块已被 clear/trim,无需删
387
+ content.deleteFrom(start, n);
388
+ const g = getGeo();
389
+ state.contentRow = Math.max(g.contentTop, state.contentRow - n);
390
+ state.contentCol = 1;
391
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
392
+ state.scrollOffset = Math.min(state.scrollOffset, maxOff);
393
+ repaintViewport();
394
+ }
395
+ /**
396
+ * 在绝对行索引 after 后插入自洽行(详情展开用)。
397
+ * 滚动回看冻结:scrollOffset>0 时 offset += delta 把窗口冻在原绝对行,
398
+ * 与 contentWrite/contentWriteMd 同模式。插入后 repaintViewport 重画 viewport。
399
+ *
400
+ * 续写位(contentRow):若原写头在插入点之后,前移 lines.length,保持相对位置;
401
+ * 若原写头 ≤ after,不变(新行在写头之后)。非 TTY 直接调 content.insertAfter。
402
+ *
403
+ * keepViewport:鼠标点击展开时 true(视口锚定原位,详情在下方展开,屏幕不跳);
404
+ * 子 agent 实时嵌套渲染时传 false —— 那是"新内容"而非"回看展开",必须跟随屏底,
405
+ * 否则每插一行就把视口冻住 1 行,子 agent 跑起来后主内容区看着像卡住不动。
406
+ */
407
+ export function contentInsertAfter(after, lines, keepViewport = true) {
408
+ if (!state.active || lines.length === 0)
409
+ return;
410
+ const g = getGeo();
411
+ const totalBefore = content.totalRows();
412
+ const scrolled = state.scrollOffset > 0;
413
+ content.insertAfter(after, lines);
414
+ const delta = content.totalRows() - totalBefore;
415
+ if (delta === 0)
416
+ return;
417
+ // 流式 md 段活跃期间:插入点在段起点之前 → segmentStartRow 平移,
418
+ // 否则 contentWriteMd 的续写位算错,展开行被下一个 setLines 砍掉。
419
+ if (state.mdActive && after + 1 < state.segmentStartRow) {
420
+ state.segmentStartRow += delta;
421
+ }
422
+ // 续写位:原写头绝对行 = viewport 起点 + (contentRow-1) [offset=0];偏移后才一致
423
+ if (state.scrollOffset === 0 && state.contentRow > after + 1) {
424
+ state.contentRow = Math.min(state.contentRow + delta, g.contentBottom);
425
+ }
426
+ // 滚动回看冻结(同 contentWrite)
427
+ if (scrolled) {
428
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
429
+ }
430
+ // 展开工具信息时保持视口位置:当 scrollOffset===0(未滚动)且插入点在视口内时,
431
+ // 计算 scrollOffset 让视口锚定在插入前的绝对行位置,展开内容在下面自然展开,
432
+ // 而不是自动跳到展开内容底部(用户体验:点击摘要行,视口不动,详情在下方展开)。
433
+ // 关键:插入点绝对行 = after;插入前视口尾行绝对行 = totalBefore - 1;
434
+ // 插入后要让原视口尾行仍在屏底 → scrollOffset = 插入后新增的、在原视口尾行之后的行数。
435
+ if (keepViewport && !scrolled && after < totalBefore) {
436
+ // 插入点在原缓冲内(非追加到末尾),计算需要滚动的偏移量
437
+ const insertedAfterViewport = after >= totalBefore - g.contentBottom;
438
+ if (insertedAfterViewport) {
439
+ // 插入点在视口内:保持视口位置,scrollOffset = 插入行数(展开内容在视口下方)
440
+ state.scrollOffset = Math.min(delta, Math.max(0, content.totalRows() - g.contentBottom));
441
+ }
442
+ }
443
+ // 必须同步平移 batch 索引。若延迟到 dynamic import.then,下一条 mutation 可能已经
444
+ // 按插入后的 buffer 创建;旧回调会再平移它一次,导致详情插到空行之后。
445
+ shiftBatchesAfter(after, delta);
446
+ repaintViewport();
447
+ }
448
+ /**
449
+ * 从绝对行索引 startIdx 起删 n 行(折叠回退用)。
450
+ * 滚动回看冻结同 insertAfter:offset -= delta,钳 ≥ 0。续写位若在删区后前移 delta。
451
+ */
452
+ export function contentDeleteFrom(startIdx, n) {
453
+ if (!state.active || n <= 0)
454
+ return;
455
+ const totalBefore = content.totalRows();
456
+ const scrolled = state.scrollOffset > 0;
457
+ content.deleteFrom(startIdx, n);
458
+ const delta = totalBefore - content.totalRows();
459
+ if (delta === 0)
460
+ return;
461
+ // 流式 md 段活跃期间:删除区间全部在段起点之前 → segmentStartRow 回退,
462
+ // 否则 contentWriteMd 的续写位算错,段内容错位。
463
+ if (state.mdActive && startIdx + delta <= state.segmentStartRow) {
464
+ state.segmentStartRow = Math.max(1, state.segmentStartRow - delta);
465
+ }
466
+ if (state.scrollOffset === 0 && state.contentRow > startIdx + 1) {
467
+ state.contentRow = Math.max(1, state.contentRow - delta);
468
+ }
469
+ if (scrolled) {
470
+ state.scrollOffset = Math.max(0, state.scrollOffset - delta);
471
+ }
472
+ // batch 摘要行索引平移(用 -delta 表示后段索引前移),同插入路径必须同步。
473
+ shiftBatchesAfter(startIdx, -delta);
474
+ repaintViewport();
475
+ }
476
+ /** 当前内容物理行总数(含当前未提交行)。供 batch 渲染器在 endBatch 时定位摘要行索引。 */
477
+ export function totalRows() {
478
+ return content.totalRows();
479
+ }
480
+ /** 缓冲尾部(已提交行)是否已经是空白行(去掉 ANSI 后无可见字符)。
481
+ * 供 compact 等在 step 循环顶部写通知行前判断是否需要补空行分隔。 */
482
+ export function isLastContentRowBlank() {
483
+ const committed = content.committedRows();
484
+ if (committed === 0)
485
+ return false;
486
+ const line = content.lineAt(committed - 1);
487
+ if (line === null)
488
+ return false;
489
+ return line.replace(/\x1b\[[0-9;]*m/g, '').trim().length === 0;
490
+ }
491
+ /** 正文→mutation 首摘要前,把尾部间距强制归一为一条视觉空行。 */
492
+ export function normalizeMutationBoundary() {
493
+ if (!state.active || !ui.isTTY)
494
+ return;
495
+ if (state.mdActive)
496
+ commitMd();
497
+ const totalBefore = content.totalRows();
498
+ content.normalizeTrailingBlankRows(1);
499
+ const g = getGeo();
500
+ const delta = content.totalRows() - totalBefore;
501
+ if (state.scrollOffset > 0 && delta !== 0) {
502
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
503
+ }
504
+ state.contentRow = Math.min(content.committedRows() + 1, g.contentBottom);
505
+ state.contentCol = 1;
506
+ if (state.scrollOffset === 0)
507
+ repaintViewport();
508
+ }
509
+ /** 命令/Agent 输出→下一条输入气泡前,统一保留恰好一条视觉空行。 */
510
+ export function normalizeInputBoundary() {
511
+ normalizeMutationBoundary();
512
+ }
513
+ /** 原地刷新一条内容行(行数不变),用于运行中的工具 batch 更新计数。 */
514
+ export function contentReplaceLine(absIdx, line) {
515
+ if (!state.active)
516
+ return;
517
+ // 原地更新的 batch 摘要可能随追加工具而变长。缓冲区仍把它当作一行,但若直接
518
+ // 交给终端超过 cols,终端会自动折行,造成视觉上多出空行且续写位与缓冲失步。
519
+ // 在写回缓冲前按 ANSI 可见宽度截断,确保“一条逻辑行 = 一条物理行”。
520
+ content.replaceLine(absIdx, truncateAnsi(line, getGeo().cols));
521
+ repaintViewport();
522
+ }
523
+ /** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
524
+ export function notifyContentReset() {
525
+ // 必须同步清理:clearContent() 后调用方会立即 renderHistory() 重建 batch。
526
+ // 若异步 reset,旧清理会在回放完成后反过来抹掉新摘要的点击映射。
527
+ resetBatches();
528
+ }
529
+ /** 把物理行索引映射到一次 reflow 之后的新索引。段内锚点按段内相对进度映射,
530
+ * 段后的索引整体平移;用于 scroll viewport 锚点和选区在 resize 后保持语义位置。 */
531
+ function remapLineForReflow(line, change) {
532
+ const oldEnd = change.start + change.oldCount;
533
+ if (line < change.start)
534
+ return line;
535
+ if (line >= oldEnd)
536
+ return Math.max(0, line + change.delta);
537
+ if (change.newCount <= 0)
538
+ return Math.max(0, change.start - 1);
539
+ if (change.oldCount <= 1)
540
+ return change.start;
541
+ const relative = (line - change.start) / Math.max(1, change.oldCount - 1);
542
+ return change.start + Math.round(relative * Math.max(0, change.newCount - 1));
543
+ }
544
+ /** 终端尺寸变化后更新正文布局。列宽变化时重排 markdown;仅高度变化时只重算屏幕锚点。 */
545
+ export function reflowContentForResize(cols, colsChanged) {
546
+ const oldTotal = content.totalRows();
547
+ const oldViewportStart = viewportAbsStart();
548
+ const oldViewportEnd = Math.max(oldViewportStart, oldTotal - state.scrollOffset - 1);
549
+ let mappedViewportEnd = oldViewportEnd;
550
+ const changes = colsChanged ? content.reflowMarkdown(cols, renderMarkdown) : [];
551
+ for (const change of changes) {
552
+ shiftBatchesForReflow(change.start, change.oldCount, change.newCount);
553
+ mappedViewportEnd = remapLineForReflow(mappedViewportEnd, change);
554
+ if (state.selection) {
555
+ const remapEndpoint = (line, col) => {
556
+ const oldEnd = change.start + change.oldCount;
557
+ if (change.oldLines.length > 0 && line >= change.start && line < oldEnd) {
558
+ const mapped = remapWrappedPoint(change.oldLines, change.newLines, { line: line - change.start, col });
559
+ return { line: change.start + mapped.line, col: mapped.col };
560
+ }
561
+ return { line: remapLineForReflow(line, change), col };
562
+ };
563
+ const anchor = remapEndpoint(state.selection.anchorLine, state.selection.anchorCol);
564
+ const end = remapEndpoint(state.selection.endLine, state.selection.endCol);
565
+ state.selection.anchorLine = anchor.line;
566
+ state.selection.anchorCol = anchor.col;
567
+ state.selection.endLine = end.line;
568
+ state.selection.endCol = end.col;
569
+ }
570
+ }
571
+ const g = getGeo();
572
+ const total = content.totalRows();
573
+ if (state.scrollOffset > 0) {
574
+ // 滚动回看时保持原窗口尾部的语义锚点,不因段行数变化跳到最新内容。
575
+ state.scrollOffset = Math.max(0, Math.min(total - mappedViewportEnd - 1, Math.max(0, total - g.contentBottom)));
576
+ }
577
+ state.contentRow = Math.min(content.committedRows() + 1, g.contentBottom);
578
+ const currentRaw = content.currentRowRaw();
579
+ state.contentCol = currentRaw === null ? 1 : ansiDisplayWidth(currentRaw) + 1;
580
+ const activeStart = content.activeSegmentStart();
581
+ if (activeStart !== null) {
582
+ // segmentStartRow 始终描述“回尾视图”中的段起点;即便用户正在滚动回看,
583
+ // 后续流式 chunk 也应继续按尾部坐标推进,不能把历史 viewport 的 offset 算进来。
584
+ const tailViewportStart = Math.max(0, total - g.contentBottom);
585
+ state.segmentStartRow = Math.max(1, Math.min(g.contentBottom, activeStart - tailViewportStart + 1));
586
+ }
587
+ }