mocode-ai 0.1.7 → 0.1.8

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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * File Tree Encoder(glob):扁平路径列表 → 按目录分组缩进树。
3
+ *
4
+ * 输入:glob 返回的路径(每行一条,\n 拼接),可能带尾部 `... (共 N 个,仅显示前 200)`。
5
+ * 输出:目录头(以 `/` 结尾)+ 其下 2 空格缩进的文件,顶部 `# N files · tree-encoded` 计数。
6
+ *
7
+ * 不变量(离线脚本断言):路径条数与集合保真——每个原始路径可从树还原:
8
+ * 目录头 `dir/` + 其下缩进行 ` file` → `dir/file`;根文件(无缩进、不以 `/` 结尾)→ 自身。
9
+ * 小输入(≤1 路径,含 `无匹配文件` 单行)原样返回:tree 无收益且避免计数头开销。
10
+ * 路径分隔符归一化(`\`→`/`):Windows 下 glob 可能返 `\`,树内统一 `/`。
11
+ */
12
+ function normalizeSep(p) {
13
+ return p.replace(/\\/g, '/');
14
+ }
15
+ export const treeEncoder = {
16
+ kind: 'tree',
17
+ encode({ output }) {
18
+ const lines = output.split('\n');
19
+ // 分离路径与尾部标记(glob 的 `... (共 N 个...)`)。首个非路径行起全部视作 tail 原样保留。
20
+ const paths = [];
21
+ const tail = [];
22
+ let inTail = false;
23
+ for (const l of lines) {
24
+ if (!l)
25
+ continue;
26
+ if (!inTail && (l.startsWith('...') || /^\(共/.test(l))) {
27
+ inTail = true;
28
+ }
29
+ if (inTail)
30
+ tail.push(l);
31
+ else
32
+ paths.push(l);
33
+ }
34
+ if (paths.length <= 1) {
35
+ // ≤1 路径:tree 无收益(含 `无匹配文件`),原样返回。
36
+ return {
37
+ text: output,
38
+ meta: {
39
+ kind: 'tree',
40
+ originalLen: output.length,
41
+ encodedLen: output.length,
42
+ note: '≤1 path → passthrough',
43
+ },
44
+ };
45
+ }
46
+ // 按目录分组(保留首次出现顺序,保还原后顺序与原一致)
47
+ const groups = new Map();
48
+ const order = [];
49
+ for (const p of paths) {
50
+ const norm = normalizeSep(p);
51
+ const idx = norm.lastIndexOf('/');
52
+ const dir = idx >= 0 ? norm.slice(0, idx + 1) : ''; // 含尾斜杠
53
+ const file = idx >= 0 ? norm.slice(idx + 1) : norm;
54
+ if (!groups.has(dir)) {
55
+ groups.set(dir, []);
56
+ order.push(dir);
57
+ }
58
+ groups.get(dir).push(file);
59
+ }
60
+ const out = [`# ${paths.length} files · tree-encoded`];
61
+ for (const dir of order) {
62
+ const files = groups.get(dir);
63
+ if (dir === '') {
64
+ for (const f of files)
65
+ out.push(f);
66
+ }
67
+ else {
68
+ out.push(dir);
69
+ for (const f of files)
70
+ out.push(` ${f}`);
71
+ }
72
+ }
73
+ if (tail.length)
74
+ out.push(...tail);
75
+ const text = out.join('\n');
76
+ return {
77
+ text,
78
+ meta: {
79
+ kind: 'tree',
80
+ originalLen: output.length,
81
+ encodedLen: text.length,
82
+ note: `${paths.length} files / ${order.length} dirs`,
83
+ },
84
+ };
85
+ },
86
+ };
@@ -13,7 +13,7 @@ import * as mouse from '../ui/mouse.js';
13
13
  import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
14
14
  import { promptIntervention } from '../ui/intervention.js';
15
15
  import { tools } from '../tools/registry.js';
16
- import { estimateMessagesTokens, estimateToolSchemaTokens, } from '../llm/index.js';
16
+ import { estimateMessagesTokens, } from '../llm/index.js';
17
17
  import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
18
18
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
19
19
  import { listSkills, effectiveSystemPrompt } from '../skills/index.js';
@@ -86,11 +86,11 @@ async function askLine(prompt) {
86
86
  rl.close();
87
87
  }
88
88
  }
89
- /** /context 的用量条(详情版,进内容区):优先用上次 chat() 返回的实测 usage,否则用启发式估算。 */
89
+ /** /context 的用量条(详情版,进内容区):只算对话内容(不含 system prompt),方便用户感知自己发了多少、agent 回复了多少。 */
90
90
  function renderContextBar(history) {
91
- const schema = estimateToolSchemaTokens();
92
- const est = contextState.lastUsage?.totalTokens ??
93
- estimateMessagesTokens(history) + schema;
91
+ // 过滤掉 system 消息,只算对话内容
92
+ const dialog = history.filter(m => m.role !== 'system');
93
+ const est = estimateMessagesTokens(dialog);
94
94
  const win = config.contextWindowTokens;
95
95
  const pct = Math.min(1, est / win);
96
96
  const W = 10;
@@ -101,11 +101,12 @@ function renderContextBar(history) {
101
101
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
102
102
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}`;
103
103
  }
104
- /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。 */
104
+ /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
105
+ * 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
105
106
  function renderContextBarInline(history) {
106
- const schema = estimateToolSchemaTokens();
107
- const est = contextState.lastUsage?.totalTokens ??
108
- estimateMessagesTokens(history) + schema;
107
+ // 过滤掉 system 消息,只算对话内容
108
+ const dialog = history.filter(m => m.role !== 'system');
109
+ const est = estimateMessagesTokens(dialog);
109
110
  const win = config.contextWindowTokens;
110
111
  const pct = Math.min(1, est / win);
111
112
  const W = 10;
@@ -189,9 +190,8 @@ function onRunningKey(_str, key) {
189
190
  }
190
191
  // 用户在交互(非滚动键)→ 暂停流式物理写,避免光标去 contentRow 扰动 IME 候选窗(停手后自动 flush)
191
192
  layout.setUserActive();
192
- // 其他键:若处于滚动回看,先回尾再处理(打字即回底)
193
- if (layout.isScrolled())
194
- layout.resetScroll();
193
+ // 滚动回看时打字 / 编辑(typeahead)不回尾——保持历史视图,便于运行中边看历史边预输入;
194
+ // 回尾时机:Enter 在运行态是 no-op,真正回尾发生在 agent 结束后 INPUT 态按 Enter 提交(见 prompt.ts submit 前)
195
195
  // Ctrl+C 4 层语义(RUNNING 态):有 typeahead → 清空(层 1,不中断);空 → abort(层 2,中断 agent)。
196
196
  // 两次 Ctrl+C 才中断(先清 typeahead 再 abort),与 INPUT 态 onCtrlC 的 fish 式一致。
197
197
  // raw 模式下 Ctrl+C 是按键不触发 SIGINT;signal 经 executeTool 串进工具,run_command/web_fetch 即时被杀。
@@ -23,6 +23,47 @@ export function truncateMid(text, max) {
23
23
  const out = text.slice(0, head) + marker + text.slice(text.length - tail);
24
24
  return out.length > max ? out.slice(0, max) : out;
25
25
  }
26
+ /**
27
+ * Provenance 压缩:旧 tool_calls.arguments 超长时,把大字符串字段替换为 "<N 字符,已省略>" stub,
28
+ * 保留 path(/rollback planRollback 靠 JSON.parse(arguments).path 找文件,见 rollback/index.ts)+ 其余短字段。
29
+ * 比 truncateMid 的 head+tail 片段更短且更易读:LLM/摘要器看到 "<5000 字符,已省略>" 即知"写过 5000 字符",
30
+ * 而非混乱的 head+tail 片段。
31
+ *
32
+ * 保:JSON 合法(parse/stringify 失败均原样返回)+ tool_call_id 配对不动(只改 arguments 内容,不改
33
+ * tool_calls 数组结构)+ path 永不省(rollback 依赖)。永不抛错(对齐「调度器永不抛错」契约)。
34
+ *
35
+ * 触发:整体 arguments > MAX_OLD_TOOL_STUB 才进(同原 truncateMid 门槛);字段级也 > MAX_OLD_TOOL_STUB 才 stub。
36
+ */
37
+ function stubToolCallArguments(argsRaw) {
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(argsRaw);
41
+ }
42
+ catch {
43
+ return argsRaw; // 非合法 JSON(模型偶发):不碰
44
+ }
45
+ if (!parsed || typeof parsed !== 'object')
46
+ return argsRaw;
47
+ const obj = parsed;
48
+ let changed = false;
49
+ for (const k of Object.keys(obj)) {
50
+ if (k === 'path')
51
+ continue; // path 永不省:/rollback planRollback 靠它定位文件
52
+ const v = obj[k];
53
+ if (typeof v === 'string' && v.length > MAX_OLD_TOOL_STUB) {
54
+ obj[k] = `<${v.length} 字符,已省略>`;
55
+ changed = true;
56
+ }
57
+ }
58
+ if (!changed)
59
+ return argsRaw;
60
+ try {
61
+ return JSON.stringify(obj);
62
+ }
63
+ catch {
64
+ return argsRaw; // stringify 失败(理论不会):不碰
65
+ }
66
+ }
26
67
  /**
27
68
  * push-time 第一层:工具结果进 history 前裁到 MAX_HISTORY_RESULT。
28
69
  * 显示层(summarizeToolResult)仍用原 output,不受影响。
@@ -208,8 +249,8 @@ export async function compactHistory(history, opts) {
208
249
  // 第一层:微压缩——旧区原地截短(保 tool_call_id,无 LLM 调用)
209
250
  // 覆盖三类大字段,均只裁模型/工具产物,不动 user 原话与 system(摘要):
210
251
  // ① tool 结果 content;
211
- // ② 旧 assistant 的 tool_calls.arguments —— JSON 合法:整体超长才进,
212
- // 逐字段值裁中截后重新 stringify(不裸中截会劈断 JSON、严格后端 400);parse 失败跳过;
252
+ // ② 旧 assistant 的 tool_calls.arguments —— provenance stub:整体超长才进,大字段
253
+ // 替换为 "<N 字符,已省略>"( path + JSON 合法,见 stubToolCallArguments);
213
254
  // ③ 旧 assistant 正文 content(模型长解释,回看价值低)。
214
255
  let microcompactDone = false;
215
256
  for (const g of oldGroups) {
@@ -227,27 +268,17 @@ export async function compactHistory(history, opts) {
227
268
  as.content = truncateMid(as.content, MAX_OLD_TOOL_STUB);
228
269
  microcompactDone = true;
229
270
  }
230
- // ② tool_calls 参数:整体超长才进,逐字段裁值,保 JSON 合法
271
+ // ② tool_calls 参数:整体超长才进,provenance stub 大字段(保 path + JSON 合法)。
272
+ // stubToolCallArguments:大字符串字段 → "<N 字符,已省略>";path 永不省(/rollback 依赖)。
231
273
  if (Array.isArray(as.tool_calls)) {
232
274
  for (const tc of as.tool_calls) {
233
275
  const args = tc?.function?.arguments;
234
276
  if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
235
277
  continue;
236
- try {
237
- const parsed = JSON.parse(args);
238
- if (parsed && typeof parsed === 'object') {
239
- for (const k of Object.keys(parsed)) {
240
- const v = parsed[k];
241
- if (typeof v === 'string' && v.length > MAX_OLD_TOOL_STUB) {
242
- parsed[k] = truncateMid(v, MAX_OLD_TOOL_STUB);
243
- }
244
- }
245
- tc.function.arguments = JSON.stringify(parsed);
246
- microcompactDone = true;
247
- }
248
- }
249
- catch {
250
- // arguments 非合法 JSON(模型偶发):不裁,沿用「调度器永不抛错」
278
+ const stubbed = stubToolCallArguments(args);
279
+ if (stubbed !== args) {
280
+ tc.function.arguments = stubbed;
281
+ microcompactDone = true;
251
282
  }
252
283
  }
253
284
  }
package/dist/ui/layout.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { stdin, stdout } from 'node:process';
2
- import { charWidth, displayWidth, truncateDisplay, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } from './render.js';
2
+ import { appendFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } from './render.js';
3
6
  import { ui } from './theme.js';
4
7
  import * as content from './content.js';
5
8
  import * as mouse from './mouse.js';
@@ -7,11 +10,17 @@ import { renderMarkdown } from './markdown.js';
7
10
  // ── 内部状态 ──
8
11
  let active = false;
9
12
  let mode = 'input';
10
- let footerH = 4; // 1 状态行 + 1 上线 + 输入行数 + 1 下线(上下线框住输入区)
13
+ let footerH = 5; // 1 虚拟空行 + 1 状态行 + 1 上线 + 输入行数 + 1 下线(虚拟空行隔开内容与状态栏,上下线框住输入区)
11
14
  let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
12
15
  let contentCol = 1; // 续写位列(1-based)
16
+ // 上一次 paintLiveAtCursor 实际画帧的屏坐标(0=未画)。clearLiveAtCursor 清"这行"而非当前续写位——
17
+ // 防 spinner 运行期间续写位漂移时清错行、旧帧行残留(见 557e678 移除 isStreamingPaused 后的间歇性 frame 泄漏)。
18
+ let frameRow = 0;
19
+ let frameCol = 0;
13
20
  let segmentStartRow = 1; // 当前 md 段起始屏行(供 contentWriteMd 定位段末续写位;段内行数由 content 段标记跟踪)
14
21
  let scrollOffset = 0; // 滚动回看距尾行数(0=尾,跟随新内容);>0 时 viewport 显历史、状态行显滚动指示
22
+ let scrollLockUntil = 0; // 发消息轮首滚动锁(绝对时间戳 ms,0=未锁):吸收 stdin 残留滚轮事件,防 resetScroll 回尾后被重新滚上去
23
+ const SCROLL_LOCK_MS = 400; // 锁时长:覆盖 OS 缓冲残留 + 常规滚轮惯性;LLM TTFB 多 >200ms,不影响轮中后段滚动
15
24
  let base = null;
16
25
  let statusText = '';
17
26
  let spinnerFrame;
@@ -86,25 +95,27 @@ export function setRegion(fh) {
86
95
  contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
87
96
  return g;
88
97
  }
89
- /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+3(resize 安全),列对齐 renderDimInputRow 的假光标(空→3,有字→❯+截断文本宽+1)。供 IME 锚定。 */
98
+ /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+4(resize 安全),列对齐 renderDimInputRow 的假光标(空→3,有字→❯+截断文本宽+1)。供 IME 锚定。 */
90
99
  function runningCaretPos() {
91
100
  const g = getGeo();
92
101
  const text = lastView?.dim ? lastView.lines[0] ?? '' : '';
93
102
  const contentW = Math.max(0, g.cols - 3); // ❯ =2 + 光标=1
94
- const w = displayWidth(truncateDisplay(text, contentW));
95
- return { row: g.contentBottom + 3, col: Math.min(g.cols, 2 + w + 1) };
103
+ const w = displayWidth(truncateDisplayHead(text, contentW));
104
+ return { row: g.contentBottom + 4, col: Math.min(g.cols, 2 + w + 1) };
96
105
  }
97
106
  export function contentMode() {
98
107
  if (!active)
99
108
  return;
100
109
  const g = getGeo();
101
- if (mode === 'running' && scrollOffset === 0) {
102
- // 运行态:真光标归输入框光标位(供 IME 锚定,气泡不跟流式跑);假光标做可见指示
110
+ if (mode === 'running') {
111
+ // 运行态(回尾 / 滚动回看均):真光标归输入框光标位(供 IME 锚定,气泡不跟流式跑)
112
+ // 滚动态也归输入框——否则上滑看历史时打字,IME 候选气泡会锚到内容区底而非输入框
113
+ // (conhost IME 不跟随 cup 后续移动,须让打字前光标已在输入框)。
103
114
  const p = runningCaretPos();
104
115
  stdout.write(cup(p.row, p.col));
105
116
  }
106
117
  else {
107
- // INPUT 态 / 滚动回看:归续写位(滚动回看时归内容区底,viewport 锁历史)
118
+ // INPUT 态:回尾归续写位,滚动回看归内容区底(viewport 锁历史;INPUT 态无 IME 锚定需求)
108
119
  stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
109
120
  }
110
121
  }
@@ -252,10 +263,10 @@ export function contentWrite(s) {
252
263
  scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
253
264
  }
254
265
  }
255
- // 物理写(回尾 offset=0 且未暂停):cup 写起点 + s + (pending 时补 \n 提交换行)+ (运行态 cup 回输入框)。
256
- // 用户打字中(isStreamingPaused)跳过物理写、只喂缓冲——光标不入内容区,IME 候选窗不跟流式跑;
257
- // 停手后 setUserActive 的 flush 定时器 repaintViewport 重画缓冲内容。
258
- if (scrollOffset === 0 && !isStreamingPaused()) {
266
+ // 物理写(回尾 offset=0):cup 写起点 + s + (pending 时补 \n 提交换行)+ (运行态 cup 回输入框)。
267
+ // 打字中不再暂停物理写——单次 write 结尾 cup 回 runningCaretPos(输入框),IME 锚定不动(跟踪每次 write
268
+ // 最终位置);旧设计 isStreamingPaused 暂停写致流式卡顿,现单次写归位已无需暂停。
269
+ if (scrollOffset === 0) {
259
270
  let out = cup(startRow, startCol) + s;
260
271
  if (pendingWrap)
261
272
  out += '\n'; // 滚动区域底行触发 DECSTBM 上滚、中段 LF 下移到 (下一行,1)
@@ -290,7 +301,7 @@ function commitMd() {
290
301
  *
291
302
  * 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
292
303
  * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
293
- * 用户打字中(isStreamingPaused)或滚动回看(scrollOffset>0)只更新缓冲,停手 flush / 回尾时显。
304
+ * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
294
305
  */
295
306
  export function contentWriteMd(s) {
296
307
  if (!active || !ui.isTTY) {
@@ -319,8 +330,8 @@ export function contentWriteMd(s) {
319
330
  scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
320
331
  }
321
332
  }
322
- if (scrollOffset === 0 && !isStreamingPaused()) {
323
- repaintViewport();
333
+ if (scrollOffset === 0) {
334
+ repaintViewport(); // 单次 write 结尾 cup 回 runningCaretPos(运行态),IME 锚输入框;打字中不再暂停
324
335
  if (mode === 'running') {
325
336
  const p = runningCaretPos();
326
337
  stdout.write(cup(p.row, p.col));
@@ -351,8 +362,11 @@ export function clearContent() {
351
362
  setRegion(footerH);
352
363
  contentRow = 1;
353
364
  contentCol = 1;
365
+ frameRow = 0;
366
+ frameCol = 0;
354
367
  segmentStartRow = 1;
355
368
  scrollOffset = 0;
369
+ scrollLockUntil = 0;
356
370
  mdActive = false;
357
371
  mdBuf = '';
358
372
  content.reset();
@@ -378,14 +392,24 @@ export function repaintViewport() {
378
392
  const line = slice[r - 1] ?? '';
379
393
  p += cup(r, 1) + esc.clearLine + line;
380
394
  }
395
+ // 光标:合并进同一 write(若拆成两次 stdout.write,行写完光标会暂留 contentRow/contentBottom,
396
+ // 流式每 chunk 调一次 → contentBottom 频繁现块状光标白块;打字第一键赶上这瞬 IME 候选气泡锚到 contentBottom)。
397
+ // 运行态(回尾 / 滚动均)归输入框 runningCaretPos(供 IME 锚定);INPUT 态回尾归续写位 / 滚动归内容区底。
398
+ if (mode === 'running') {
399
+ const c = runningCaretPos();
400
+ p += cup(c.row, c.col);
401
+ }
402
+ else {
403
+ p += cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1);
404
+ }
381
405
  stdout.write(p);
382
- // 光标:offset=0 回续写位(尾行末,contentWrite 维护);offset>0 留内容区底
383
- stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
384
406
  }
385
407
  /** 滚动 delta 行(正=往新、负=往旧);钳 [0, max(0, total-contentBottom)];变则重画 + 刷底栏(显指示、光标回输入框)。 */
386
408
  export function scrollBy(delta) {
387
409
  if (!active)
388
410
  return;
411
+ if (Date.now() < scrollLockUntil)
412
+ return; // 轮首滚动锁:吸收发消息前后 stdin 残留滚轮事件,保 agent 输出从底部开始
389
413
  const g = getGeo();
390
414
  const total = content.totalRows();
391
415
  const maxOff = Math.max(0, total - g.contentBottom);
@@ -404,6 +428,21 @@ export function resetScroll() {
404
428
  repaintViewport();
405
429
  repaint();
406
430
  }
431
+ /** 发消息轮首短时锁住滚动:吸收 stdin 残留滚轮事件(发消息前后的滚轮惯性 / OS 缓冲延迟到达),
432
+ * 防 resetScroll 回尾后被 onRunningKey 接到的残留事件重新滚上去——致 agent 输出进缓冲、显示在历史区。
433
+ * 锁只挡 scrollBy(滚轮 / PgUp-PgDn);不影响 contentWrite 写屏(offset=0 时照常物理写,agent 输出从底部开始)。
434
+ * 默认 SCROLL_LOCK_MS 后自动解锁;enterInputMode(轮末)也清锁。用户轮中后段仍可上滑(满足"输出时能看历史")。 */
435
+ export function lockScrollToBottom(ms = SCROLL_LOCK_MS) {
436
+ scrollLockUntil = Date.now() + ms;
437
+ }
438
+ /** 解锁(轮末 enterInputMode / 测试用)。 */
439
+ export function unlockScroll() {
440
+ scrollLockUntil = 0;
441
+ }
442
+ /** 轮首滚动锁是否生效(测试用)。 */
443
+ export function isScrollLocked() {
444
+ return Date.now() < scrollLockUntil;
445
+ }
407
446
  // ── 状态行 ──
408
447
  /** 组合状态行可见串(带色),按 cols 截断防溢出(不折到输入行)。 */
409
448
  function composeStatus(status, cols) {
@@ -458,14 +497,14 @@ function composeStatus(status, cols) {
458
497
  export function drawStatusBar(status) {
459
498
  if (!active || !base)
460
499
  return;
461
- if (isStreamingPaused())
462
- return; // 用户打字中:状态行暂不刷(避免光标去 statusRow 扰动 IME),停手后 flush 会刷
463
500
  const s = status ?? { ...base, status: statusText, spinnerFrame };
464
501
  const g = getGeo();
465
- const statusRow = g.contentBottom + 1;
466
- // 一次写入:cup 状态行 + clearLine + 状态 + cup 回(运行态→输入框供 IME 锚定;否则续写位/内容区底)
502
+ const statusRow = g.contentBottom + 2; // +1 虚拟空行(内容与状态栏间隔),+2 状态行
503
+ // 一次写入:cup 状态行 + clearLine + 状态 + cup 回(运行态→输入框供 IME 锚定;否则续写位/内容区底)
504
+ // 打字中不再跳过——单次 write 结尾 cup 回 runningCaretPos,IME 锚输入框不动;旧 isStreamingPaused 门致状态行 / 心跳打字时冻住。
467
505
  let out = cup(statusRow, 1) + esc.clearLine + composeStatus(s, g.cols);
468
- if (mode === 'running' && scrollOffset === 0) {
506
+ if (mode === 'running') {
507
+ // 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
469
508
  const p = runningCaretPos();
470
509
  out += cup(p.row, p.col);
471
510
  }
@@ -504,32 +543,76 @@ function stopTurnTimer() {
504
543
  turnTimer = null;
505
544
  }
506
545
  }
546
+ // ── 临时诊断:spinner frame 泄漏追踪(定位 557e678 后的间歇性泄漏后删除)──
547
+ // 记 paintLiveAtCursor/clearLiveAtCursor 的可疑时序:续写位漂移、clear 被守卫跳过、清错行。
548
+ // 同步追加到 ~/.mocode/spinner-debug.log,全 try/catch 不抛、不阻塞、不抢屏。
549
+ let _dbgSpinnerPath = '';
550
+ function dbgSpinner(msg) {
551
+ try {
552
+ if (!_dbgSpinnerPath)
553
+ _dbgSpinnerPath = join(homedir(), '.mocode', 'spinner-debug.log');
554
+ appendFileSync(_dbgSpinnerPath, `[${new Date().toISOString()}] ${msg}\n`);
555
+ }
556
+ catch {
557
+ // 诊断日志失败不影响渲染
558
+ }
559
+ }
507
560
  /**
508
561
  * 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
509
- * 仅 TTY + offset=0(实时尾)时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
562
+ * 仅 TTY + offset=0(实时尾)+ 非打字暂停态时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
510
563
  * 配合 clearLiveAtCursor 在 spinner 停时清掉,随后 contentWrite 的结果即写在该行(spinner 不入历史缓冲)。
564
+ *
565
+ * 记 frameRow/frameCol = 实际画帧位置;clearLiveAtCursor 清"这行"而非当前续写位,防续写位漂移时清错行。
566
+ * 续写位漂移时(运行期间被某次 contentWrite 推进,罕见但 557e678 后可能)先清旧 frameRow 再画新位,避免旧帧残留。
567
+ * 打字暂停态(isStreamingPaused)spinner 不画帧——恢复 557e678 前的 spinner 隐形行为(只关 spinner,不动 contentWrite)。
511
568
  */
512
569
  export function paintLiveAtCursor(text) {
513
- if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused())
570
+ if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused()) {
571
+ // 临时诊断:画帧被守卫跳过,但此前画过帧(frameRow)→ 该帧不会被这次 paint 覆盖,泄漏嫌疑
572
+ if (frameRow && scrollOffset !== 0)
573
+ dbgSpinner(`PAINT-SKIPPED frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) off=${scrollOffset} paused=${isStreamingPaused()}`);
514
574
  return;
515
- // 一次写入:cup 续写位 + clearLine + 帧 + (运行态 cup 回输入框)——避免光标在续写位歇脚被 IME 跟踪
516
- let out = cup(contentRow, contentCol) + esc.clearLine + text;
575
+ }
576
+ // 临时诊断:续写位 != 上次画帧位置 = spinner 运行期间续写位漂移(泄漏根因嫌疑)
577
+ if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
578
+ dbgSpinner(`DRIFT-PAINT old=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode} off=${scrollOffset}`);
579
+ }
580
+ let out = '';
581
+ if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
582
+ out += cup(frameRow, frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
583
+ }
584
+ out += cup(contentRow, contentCol) + esc.clearLine + text;
517
585
  if (mode === 'running') {
518
586
  const p = runningCaretPos();
519
587
  out += cup(p.row, p.col);
520
588
  }
521
589
  stdout.write(out);
590
+ frameRow = contentRow;
591
+ frameCol = contentCol;
522
592
  }
523
- /** 清掉续写位那行瞬时活动文本(配合 paintLiveAtCursor)。 */
593
+ /** 清掉 paintLiveAtCursor 画过的那行瞬时活动文本。清"实际画过的位置"(frameRow),非当前续写位。
594
+ * 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。 */
524
595
  export function clearLiveAtCursor() {
525
- if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused())
596
+ // 临时诊断:clear 被守卫跳过,但此前画过帧(frameRow)→ 帧残留(泄漏嫌疑)
597
+ if (frameRow && (!active || !ui.isTTY || scrollOffset !== 0)) {
598
+ dbgSpinner(`CLEAR-SKIPPED frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) off=${scrollOffset} active=${active}`);
599
+ }
600
+ if (!active || !ui.isTTY || scrollOffset !== 0)
526
601
  return;
527
- let out = cup(contentRow, contentCol) + esc.clearLine;
602
+ if (!frameRow)
603
+ return; // 没画过就不清(避免误清当前续写位内容)
604
+ // 临时诊断:画帧位置 != 当前续写位 → 旧设计(清续写位)会清错行
605
+ if (frameRow !== contentRow || frameCol !== contentCol) {
606
+ dbgSpinner(`CLEAR-MISMATCH frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode}`);
607
+ }
608
+ let out = cup(frameRow, frameCol) + esc.clearLine; // 清"画过的行",非"当前续写位"
528
609
  if (mode === 'running') {
529
610
  const p = runningCaretPos();
530
611
  out += cup(p.row, p.col);
531
612
  }
532
613
  stdout.write(out);
614
+ frameRow = 0;
615
+ frameCol = 0;
533
616
  }
534
617
  /** 更新状态行基线(模型 / context / cwd / 模式标识)。repl 在轮次边界与切模式时调。 */
535
618
  export function setStatusBase(b) {
@@ -643,7 +726,7 @@ export function paintInput(view) {
643
726
  startVis: 0,
644
727
  }
645
728
  : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
646
- const needFooterH = 3 + vis.inputRows; // 1 状态 + 1 上线 + 输入行 + 1 下线
729
+ const needFooterH = 4 + vis.inputRows; // 1 虚拟空 + 1 状态 + 1 上线 + 输入行 + 1 下线
647
730
  let g = preGeo;
648
731
  if (needFooterH !== footerH) {
649
732
  // setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
@@ -661,15 +744,17 @@ export function paintInput(view) {
661
744
  const line = slice[g.contentBottom - 1] ?? '';
662
745
  buf += cup(g.contentBottom, 1) + esc.clearLine + line;
663
746
  }
747
+ // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行
748
+ buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
664
749
  // 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
665
- const statusRow = g.contentBottom + 1;
750
+ const statusRow = g.contentBottom + 2; // +1 虚拟空行,+2 状态行
666
751
  const status = { ...base, status: statusText, spinnerFrame };
667
752
  buf += cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols);
668
753
  // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
669
- buf += cup(g.contentBottom + 2, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
670
- // 4. 输入行(g.contentBottom+3 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
671
- const firstInputRow = g.contentBottom + 3;
672
- const inputRowsAvail = g.footerH - 3; // 去掉状态/上线/下线,留输入行
754
+ buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
755
+ // 4. 输入行(g.contentBottom+4 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
756
+ const firstInputRow = g.contentBottom + 4;
757
+ const inputRowsAvail = g.footerH - 4; // 去掉虚拟空/状态/上线/下线,留输入行
673
758
  const indent = ' '.repeat(promptW);
674
759
  const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
675
760
  for (let i = 0; i < inputRowsAvail; i++) {
@@ -704,9 +789,11 @@ export function paintInput(view) {
704
789
  }
705
790
  // 6. 光标
706
791
  if (view.dim) {
707
- // 运行态:真光标归输入框光标位( IME 锚定);滚动回看时归内容区底(viewport 锁历史)
792
+ // 运行态(回尾 / 滚动回看均):真光标归输入框光标位( IME 锚定,气泡在输入框而非内容区)
793
+ // 滚动态也归输入框——上滑看历史时打字,IME 候选气泡须锚到输入框(conhost IME 不跟随 cup 后续移动,
794
+ // 须让打字前光标已在输入框);viewport 锁历史靠 scrollOffset,不靠光标位置。
708
795
  const p = runningCaretPos();
709
- buf += cup(scrollOffset === 0 ? p.row : g.contentBottom, scrollOffset === 0 ? p.col : 1);
796
+ buf += cup(p.row, p.col);
710
797
  }
711
798
  else {
712
799
  const r = firstInputRow + vis.visLine;
@@ -727,8 +814,9 @@ function renderDimInputRow(prompt, text, placeholder, cols) {
727
814
  const caret = `${ui.reverse} ${ui.reset}`; // 反白块状光标(1 cell,与 INPUT 态同款)
728
815
  const contentW = Math.max(0, cols - promptW - 1);
729
816
  if (text.length > 0) {
730
- // 有打字:❯ dim + 文本(dim) + 反白光标(末尾)
731
- return `${ui.dim}${prompt}${truncateDisplay(text, contentW)}${ui.reset}${caret}`;
817
+ // 有打字:❯ dim + 文本(dim,超长时从头部截断保留尾部——光标恒在末尾,须始终看到刚打的字,
818
+ // 而非 truncateDisplay 那样保留开头、把刚打的内容截没,显示成卡在开头不动的假象) + 反白光标(末尾)
819
+ return `${ui.dim}${prompt}${truncateDisplayHead(text, contentW)}${ui.reset}${caret}`;
732
820
  }
733
821
  // 空:❯ dim + 反白光标(打字起点) + dim 占位 ghost
734
822
  const p = placeholder ? truncateDisplay(placeholder, contentW) : '';
@@ -744,11 +832,8 @@ export function paintRunningInputEcho(text, placeholder) {
744
832
  if (!active || !base)
745
833
  return;
746
834
  const g = getGeo();
747
- const inputRow = g.contentBottom + 3; // 运行态 footerH 恒 4:状态(+1)+上线(+2)+输入行(+3);下线在 rows
748
- stdout.write(cup(inputRow, 1) +
749
- esc.clearLine +
750
- renderDimInputRow('❯ ', text, placeholder, g.cols));
751
- // 同步 lastView:text 与 placeholder 拆开存,使滚动/resize 的 repaint 重画时光标位置正确(空→起点,有字→末尾)
835
+ const inputRow = g.contentBottom + 4; // 运行态 footerH 恒 5:虚拟空(+1)+状态(+2)+上线(+3)+输入行(+4);下线在 rows
836
+ // 先同步 lastView( runningCaretPos 算真光标位 = 新文本末尾,与假光标同位)
752
837
  lastView = {
753
838
  prompt: '❯ ',
754
839
  lines: [text],
@@ -758,9 +843,14 @@ export function paintRunningInputEcho(text, placeholder) {
758
843
  menu: null,
759
844
  dim: true,
760
845
  };
761
- // 真光标归输入框光标位(供 IME 锚定,与假光标同位);滚动回看时归内容区底
846
+ // 单次 write:cup 输入行 + clearLine + dim 文本/假光标 + cup 真光标到输入框(供 IME 锚定)
847
+ // 滚动态也归输入框——旧设计滚动态归 contentBottom,致 IME 候选气泡锚到内容区底白块
848
+ // (conhost IME 不跟随 cup 后续移动,须让打字前光标已在输入框);拆两次 write 会暂留 contentBottom 显白块。
762
849
  const p = runningCaretPos();
763
- stdout.write(cup(scrollOffset === 0 ? p.row : g.contentBottom, scrollOffset === 0 ? p.col : 1));
850
+ stdout.write(cup(inputRow, 1) +
851
+ esc.clearLine +
852
+ renderDimInputRow('❯ ', text, placeholder, g.cols) +
853
+ cup(p.row, p.col));
764
854
  }
765
855
  /** 重画当前视图(resize / 内部用)。 */
766
856
  export function repaint() {
@@ -779,6 +869,9 @@ export function enterInputMode(status = '空闲') {
779
869
  runningFrame = -1; // 回 INPUT 态:停状态行 chip 旋转,composeStatus 退回静态 ◆
780
870
  turnStart = null; // 停走时
781
871
  stopTurnTimer();
872
+ scrollLockUntil = 0; // 轮末:清轮首滚动锁,INPUT 态可自由滚动
873
+ frameRow = 0; // 轮末:清 spinner 帧位置(防下轮残留)
874
+ frameCol = 0;
782
875
  // 运行态若有未 flush 的缓冲内容(用户打字暂停了流式写),切回 INPUT 前重画内容区显示之,免丢内容
783
876
  if (flushTimer) {
784
877
  clearTimeout(flushTimer);
@@ -790,7 +883,7 @@ export function enterInputMode(status = '空闲') {
790
883
  repaintViewport();
791
884
  }
792
885
  if (active && base) {
793
- setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
886
+ setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
794
887
  paintInput({
795
888
  prompt: '❯ ',
796
889
  lines: [''],
@@ -801,15 +894,16 @@ export function enterInputMode(status = '空闲') {
801
894
  stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
802
895
  }
803
896
  }
804
- /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 4(状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
897
+ /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 5(虚拟空+状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
805
898
  export function enterRunningMode(status, placeholder) {
806
899
  mode = 'running';
807
900
  statusText = status;
808
901
  spinnerFrame = undefined;
809
902
  turnStart = Date.now(); // 起走时(整轮从发起到 enterInputMode 止)
810
903
  resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
904
+ lockScrollToBottom(); // 轮首短时锁:吸收发消息前后残留滚轮事件,保 agent 输出从底部开始(锁过期或轮末 enterInputMode 解)
811
905
  if (active && base) {
812
- setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
906
+ setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
813
907
  paintInput({
814
908
  prompt: '❯ ',
815
909
  lines: [''],
@@ -833,11 +927,12 @@ export function enterAltScreen() {
833
927
  active = true;
834
928
  stdout.write(esc.altOn);
835
929
  stdout.write(esc.mouseOn); // SGR 鼠标追踪:滚轮发 \x1B[<btn;col;rowM 报表,经 mouse.consumeMouse 重组 → scrollBy(滚轮滚动靠此 + onKey/onRunningKey 顶部守卫)
836
- setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线(底栏始终含上下线)
930
+ setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线(底栏始终含上下线)
837
931
  contentRow = 1;
838
932
  contentCol = 1;
839
933
  segmentStartRow = 1;
840
934
  scrollOffset = 0;
935
+ scrollLockUntil = 0;
841
936
  mdActive = false;
842
937
  mdBuf = '';
843
938
  content.reset();
@@ -874,6 +969,9 @@ export function exitAltScreen() {
874
969
  active = false;
875
970
  stopTurnTimer(); // 兜底清走时计时器(防异常退出泄漏)
876
971
  turnStart = null;
972
+ scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
973
+ frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
974
+ frameCol = 0;
877
975
  mouse.resetMouse(); // 清鼠标重组残留(防退出后状态泄漏到下次进 alt 屏)
878
976
  // raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
879
977
  try {