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,229 @@
1
+ // L1 选区纯计算:归一化 / 取文本 / 高亮 / 输入框坐标映射(无副作用、不写屏)。
2
+ // 从 layout-internal/core.ts 按依赖分层拆出;可变运行态集中在 ./state.ts。
3
+ import { charWidth, displayWidth, wrapByDisplayWidth, stripAnsi, sliceByDisplayCol } from '../render.js';
4
+ import * as content from '../content.js';
5
+ import { readClipboard } from '../clipboard.js';
6
+ import { state } from './state.js';
7
+ import { getGeo } from './screen.js';
8
+ /** 选区归一化(anchor/end 按阅读顺序排序为 start/end)。无选区返 null。 */
9
+ export function normalizeSelection() {
10
+ if (!state.selection)
11
+ return null;
12
+ const a = { line: state.selection.anchorLine, col: state.selection.anchorCol };
13
+ const b = { line: state.selection.endLine, col: state.selection.endCol };
14
+ const aFirst = a.line < b.line || (a.line === b.line && a.col <= b.col);
15
+ return aFirst
16
+ ? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
17
+ : { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
18
+ }
19
+ /** 给自洽带色行的显示列区间 [colStart,colEnd) 套「统一亮黄底 + 黑字」。
20
+ * 强制重设前/背景,无视原 SGR:md 字符常带 ui.dim / ui.cyan / ui.gray 等,
21
+ * 仅靠 SGR 7 反转对比度极弱;改用显式「亮黄底 SGR 103 + 黑前景 SGR 30」,
22
+ * 跨终端一致。退反白用 0 清 SGR,行末 active SGR 自然续接。
23
+ * 关键:反白 active 期间**吃掉所有行内 SGR**(不让前景色干扰)——否则
24
+ * 行内首个 \x1B[2m(dim)/\x1B[36m(cyan)/\x1B[1m(bold) 进选区后仍生效,
25
+ * 前景被压回原色,整片亮黄底被切割、看着「花」;直穿则带 dim 等,对比不足。
26
+ * 反白外 SGR 直穿,保留原色。 */
27
+ export const SEL_OPEN = '\x1B[30;103m'; // 30:黑前景 | 103:亮黄背景
28
+ export const SEL_OFF = '\x1B[0m'; // 全清 SGR,行末 active 状态续接
29
+ export function highlightRange(line, colStart, colEnd) {
30
+ if (colEnd <= colStart)
31
+ return line;
32
+ const parts = line.split(/(\x1b\[[0-9;]*m)/);
33
+ let w = 0;
34
+ let out = '';
35
+ let opened = false;
36
+ for (let i = 0; i < parts.length; i++) {
37
+ if (i % 2 === 1) {
38
+ // SGR 段:反白内直接吃,不让行内前景色进选区;反白外直穿,保留原色。
39
+ if (opened)
40
+ continue;
41
+ out += parts[i];
42
+ continue;
43
+ }
44
+ for (const ch of parts[i]) {
45
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
46
+ if (!opened && w >= colStart && w < colEnd) {
47
+ out += SEL_OPEN;
48
+ opened = true;
49
+ }
50
+ if (opened && w >= colEnd) {
51
+ out += SEL_OFF;
52
+ opened = false;
53
+ }
54
+ out += ch;
55
+ w += cw;
56
+ }
57
+ }
58
+ if (opened)
59
+ out += SEL_OFF;
60
+ return out;
61
+ }
62
+ /** 从归一化选区抠出纯文本(去 ANSI,按显示列裁切,行间 \n 拼接)。越界行跳过(缓冲被 trim 等边界情况)。 */
63
+ export function extractSelectionText(sel) {
64
+ const out = [];
65
+ for (let abs = sel.startLine; abs <= sel.endLine; abs++) {
66
+ const raw = content.lineAt(abs);
67
+ if (raw == null)
68
+ continue;
69
+ const plain = stripAnsi(raw);
70
+ const lineW = displayWidth(plain);
71
+ const colStart = abs === sel.startLine ? sel.startCol : 0;
72
+ const colEnd = abs === sel.endLine ? sel.endCol : lineW;
73
+ out.push(sliceByDisplayCol(plain, colStart, colEnd));
74
+ }
75
+ return out.join('\n');
76
+ }
77
+ /** 输入框选区归一化:按 (line, col) 字典序排成 start/end。无选区返 null。 */
78
+ export function normalizeInputSelection() {
79
+ if (!state.inputSelection)
80
+ return null;
81
+ const a = state.inputSelection.anchor;
82
+ const b = state.inputSelection.end;
83
+ const aFirst = a.line < b.line || (a.line === b.line && a.col <= b.col);
84
+ return aFirst
85
+ ? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
86
+ : { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
87
+ }
88
+ /** 从归一化输入框选区抠出纯文本(lines 上不带 ANSI,直接按 display_col 切)。越界行跳到该行末尾。 */
89
+ export function extractInputSelectionText(sel) {
90
+ if (!state.lastView)
91
+ return '';
92
+ const out = [];
93
+ for (let l = sel.startLine; l <= sel.endLine; l++) {
94
+ const raw = state.lastView.lines[l];
95
+ if (raw == null)
96
+ continue;
97
+ const lineW = displayWidth(raw);
98
+ const colStart = l === sel.startLine ? sel.startCol : 0;
99
+ const colEnd = l === sel.endLine ? sel.endCol : lineW;
100
+ out.push(sliceByDisplayCol(raw, colStart, colEnd));
101
+ }
102
+ return out.join('\n');
103
+ }
104
+ /** 输入框 lastView 签名:lines 数 + 每行 display_w 累加;任何键改动 lines 即变化,用作 inputSelection 失效判据。 */
105
+ export function inputViewSig(view) {
106
+ if (!view)
107
+ return '';
108
+ return view.lines.length + '|' + view.lines.map((l) => displayWidth(l).toString()).join(',');
109
+ }
110
+ /** 屏行是否落在底栏输入行范围内(paintInput 的 firstInputRow..firstInputRow+inputRowsAvail-1)。 */
111
+ export function isInputRow(row) {
112
+ const g = getGeo();
113
+ const firstInputRow = g.contentBottom + 3 + state.planRows;
114
+ const inputRowsAvail = Math.max(0, g.footerH - 4 - state.planRows);
115
+ return row >= firstInputRow && row < firstInputRow + inputRowsAvail;
116
+ }
117
+ /** 右键单击输入行(未拖动的 press→release):读剪贴板 + 回调 pasteHandler 贴入。异步但不阻塞其他事件。 */
118
+ export function pasteIntoInput() {
119
+ if (!state.pasteHandler)
120
+ return;
121
+ const handler = state.pasteHandler;
122
+ readClipboard()
123
+ .then((text) => {
124
+ if (text && state.active)
125
+ handler(text);
126
+ })
127
+ .catch(() => { });
128
+ }
129
+ /**
130
+ * 把屏(行,列)反推到 (lines 行号, 行内 display_col, flatIdx, 段内显示列),
131
+ * 算法与 setInputCursorFromClick 抽出前等价——基于 lastView 的"折行 + 滚动窗"几何,
132
+ * 与 paintInput 的 windowInputVis 同源(startVis 同锚),保证 press/drag 落点 = 画上高亮位置。
133
+ * 越界点(空行)→ 落末行末段末。
134
+ */
135
+ export function inputScreenToInputPos(screenRow, screenCol) {
136
+ if (!state.lastView)
137
+ return null;
138
+ const g = getGeo();
139
+ const promptW = displayWidth(state.lastView.prompt);
140
+ const firstInputRow = g.contentBottom + 3 + state.planRows;
141
+ const inputRowsAvail = Math.max(0, g.footerH - 4 - state.planRows);
142
+ // 输入框可视区的 (visRow, visCol) 屏幕坐标 → 0-based。
143
+ // 点到可视区末行之下(空白区)→ 落到最末可视行(光标归最后一段);isInputRow 已挡可视区之上的点击。
144
+ const visRow = Math.max(0, Math.min(screenRow - firstInputRow, Math.max(0, inputRowsAvail - 1)));
145
+ // 屏幕列 (1-based SGR) → 显示列 (0-based),再扣 prompt 宽;尾点容许越界 clip 到 >=0。
146
+ const visCol = Math.max(0, screenCol - 1 - promptW);
147
+ // 复刻 windowInputVis 的折行 + 滚动窗(输入态, lines 全量参与)。
148
+ // lastView.lines 在 prompt 的 redraw() 中为 dispLines()(含 chip pre 行 / chip prefix 列);
149
+ // chip 模式下点击 chip 区域也走同一算法,与 paintInput 的视协议(段接缝除外)一致。
150
+ const cols = Math.max(1, g.cols - promptW);
151
+ const lineVis = state.lastView.lines.map((l) => wrapByDisplayWidth(l, cols));
152
+ const flat = [];
153
+ for (const lv of lineVis)
154
+ for (const r of lv)
155
+ flat.push(r);
156
+ const totalVis = flat.length;
157
+ const maxInputRows = Math.max(1, Math.floor(g.rows * 0.4));
158
+ // 起窗偏移:用 lastView 当前光标位置作为锚(与 paintInput 同算法)。
159
+ let curVisLine = 0;
160
+ {
161
+ const clRows = lineVis[state.lastView.cursorLine] ?? [''];
162
+ let acc = 0;
163
+ for (let i = 0; i < clRows.length; i++) {
164
+ const rw = displayWidth(clRows[i]);
165
+ if (state.lastView.cursorCol <= acc + rw) {
166
+ curVisLine = i;
167
+ break;
168
+ }
169
+ acc += rw;
170
+ curVisLine = i;
171
+ }
172
+ }
173
+ let curAbs = curVisLine;
174
+ for (let i = 0; i < state.lastView.cursorLine; i++)
175
+ curAbs += lineVis[i].length;
176
+ const startVis = totalVis > maxInputRows ? Math.max(0, Math.min(curAbs - maxInputRows + 1, totalVis - maxInputRows)) : 0;
177
+ // 点到可视区末行之下(空行)→ 等价"光标在最末可视行的字符串末尾"。
178
+ if (startVis + visRow >= totalVis) {
179
+ const lastFlatIdx = totalVis - 1;
180
+ // 把 lastFlatIdx 反推到 (line, segInLine)
181
+ let lastLine = 0;
182
+ let lastSegInLine = lastFlatIdx;
183
+ while (lastLine < lineVis.length && lastSegInLine >= lineVis[lastLine].length) {
184
+ lastSegInLine -= lineVis[lastLine].length;
185
+ lastLine++;
186
+ }
187
+ if (lastLine >= lineVis.length) {
188
+ lastLine = lineVis.length - 1;
189
+ lastSegInLine = lineVis[lastLine].length - 1;
190
+ }
191
+ // lines[lastLine] 内 display_col = 前 segInLine 段 display_w + 末段 display_w
192
+ let inLineDisplayCol = 0;
193
+ for (let s = 0; s <= lastSegInLine; s++)
194
+ inLineDisplayCol += displayWidth(lineVis[lastLine][s] ?? '');
195
+ return {
196
+ line: lastLine,
197
+ displayCol: inLineDisplayCol,
198
+ flatIdx: lastFlatIdx,
199
+ inSegVis: displayWidth(flat[lastFlatIdx]), // 段末
200
+ };
201
+ }
202
+ // flatIdx = 点击可视段在 flat 中的绝对索引(startVis + visRow)
203
+ const flatIdx = startVis + visRow;
204
+ // 把 flatIdx 反推到 (line, segInLine) — line 给 paintInput 用(同 lines[cursorLine] 行号),
205
+ // segInLine 给 prompt 做 cl/cc 反推用。
206
+ let line = 0;
207
+ let segInLine = flatIdx;
208
+ while (line < lineVis.length && segInLine >= lineVis[line].length) {
209
+ segInLine -= lineVis[line].length;
210
+ line++;
211
+ }
212
+ if (line >= lineVis.length) {
213
+ line = lineVis.length - 1;
214
+ segInLine = lineVis[line].length - 1;
215
+ }
216
+ // lines[line] 内 display_col = 前 segInLine 段 display_w 累加 + 段内显示列。
217
+ let inLineDisplayCol = 0;
218
+ for (let s = 0; s < segInLine; s++)
219
+ inLineDisplayCol += displayWidth(lineVis[line][s] ?? '');
220
+ const seg = flat[flatIdx];
221
+ const segW = displayWidth(seg);
222
+ const inSeg = Math.min(visCol, segW); // 段尾点击容许越界 clip 到段末
223
+ return {
224
+ line,
225
+ displayCol: inLineDisplayCol + inSeg,
226
+ flatIdx,
227
+ inSegVis: inSeg,
228
+ };
229
+ }
@@ -0,0 +1,82 @@
1
+ // layout 共享可变状态单例。
2
+ //
3
+ // 全屏 TUI 的各职责模块(screen / content-write / scroll / statusbar / input-paint / mouse)
4
+ // 读写同一批游标与缓存:contentRow 由 content-write 写、scroll 与 input-paint 读;
5
+ // planRows 同时驱动 statusbar 撑高与 geo 重算。这种交叉引用做不成依赖注入(会立刻循环),
6
+ // 故收进单一对象,各模块 import 同一实例。
7
+ //
8
+ // 约定:
9
+ // - 只放**可变**运行态。纯常量(esc 序列表 / SCROLL_LOCK_MS / RUNNING_FRAMES 等)留在使用它的模块。
10
+ // - 字段按职责分组,组名与 layout-internal/ 下的文件划分对齐。
11
+ // - 不要在模块顶层读 state 快照(如 `const x = state.scrollOffset`)——必须每次现取,
12
+ // 否则跨 await / 定时器会读到过期值。
13
+ export const state = {
14
+ // ── 屏幕生命周期(alt screen / console 劫持 / resize / 退出钩子) ──
15
+ active: false,
16
+ consoleHookInstalled: false,
17
+ mode: 'input',
18
+ resizeTimer: null,
19
+ lastTerminalCols: 0,
20
+ exitHandler: null,
21
+ sigwinchHandler: null,
22
+ // ── 几何(底栏高度,contentBottom = rows - footerH) ──
23
+ footerH: 6, // 1 虚拟空行 + 1 spinner行 + 1 上线 + 输入行数 + 1 下线 + 1 model行(两行式底栏)
24
+ // ── 内容写入(续写位 / markdown 流式段 / banner / 欢迎块) ──
25
+ contentRow: 1, // 续写位行(1-based,屏坐标,[1,contentBottom])
26
+ contentCol: 1, // 续写位列(1-based)
27
+ segmentStartRow: 1, // 当前 md 段起始屏行(供 contentWriteMd 定位段末续写位;段内行数由 content 段标记跟踪)
28
+ /** markdown 流式段:agent onText 的 chunk 累积到 mdBuf,每 chunk 把整段经 renderMarkdown
29
+ * 渲成自洽行,replace 缓冲段(content.setLines)+ repaintViewport 重画。mdActive 期间任何
30
+ * 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。 */
31
+ mdActive: false,
32
+ mdBuf: '',
33
+ bannerH: 0,
34
+ welcomeStart: -1, // 块起点(content.committedRows 口径的绝对行索引)
35
+ welcomeRows: 0, // 块行数(0 = 屏上无欢迎块)
36
+ // ── 滚动与流式暂停(回看冻结 / 打字期停物理写) ──
37
+ scrollOffset: 0, // 滚动回看距尾行数(0=尾,跟随新内容);>0 时 viewport 显历史、状态行显滚动指示
38
+ scrollLockUntil: 0, // 发消息轮首滚动锁(绝对时间戳 ms,0=未锁):吸收 stdin 残留滚轮事件,防 resetScroll 回尾后被重新滚上去
39
+ /** 运行态用户打字时暂停流式物理写:流式每个 token 要 cup 到 contentRow 写入,IME 候选窗逐光标移动跟踪会跟过去;
40
+ * 用户打字期间只喂缓冲、不物理写,光标留输入框;停手 USER_ACTIVE_PAUSE_MS 后 flush 重画缓冲内容。 */
41
+ userActiveUntil: 0, // 打字活跃截止时刻(Date.now()+PAUSE);0=未活跃
42
+ flushTimer: null, // 用户停手后 flush 缓冲内容(repaintViewport)
43
+ // ── 状态栏(底栏文案 / spinner / 走时 / plan chip / live 帧位) ──
44
+ base: null,
45
+ /** 运行态实时 token 用量(agent core 流式推送,轮末 repl 清 undefined)。
46
+ * composeModelLine 在 RUNNING 态把它画成 chip 放 context 进度条左侧;
47
+ * 不主动触发重画——RUNNING 态 turnTimer 80ms 心跳重画自然取最新值。 */
48
+ liveUsage: undefined,
49
+ statusText: '',
50
+ spinnerFrame: undefined,
51
+ turnStart: null, // RUNNING 态起点(Date.now());INPUT 态为 null。composeStatus 据此拼走时。
52
+ turnTimer: null, // 走时刷新计时器(独立于 spinner):流式期间 spinner 停转,由它续刷状态行。
53
+ /** 当前 plan chip 占用脚栏行数(1 或 2)。composePlanLines 每次重算后写入。
54
+ * 驱动 paintInput setRegion(fh) 动态撑高脚栏;drawStatusBar 据此画 1/2 行 plan,
55
+ * 与 spinner/上线/输入/下线/model 行的 +1/+2/+3/+4/+5 偏移天然一致(contentBottom 自动重算)。 */
56
+ planRows: 1,
57
+ runningFrame: -1, // 运行态状态行 chip 心跳帧(转圈帧 明灭);INPUT 态 -1 退回静态 ●
58
+ /** 上一次 paintLiveAtCursor 实际画帧的屏坐标(0=未画)。clearLiveAtCursor 清"这行"而非当前续写位——
59
+ * 防 spinner 运行期间续写位漂移时清错行、旧帧行残留(见 557e678 移除 isStreamingPaused 后的间歇性 frame 泄漏)。 */
60
+ frameRow: 0,
61
+ frameCol: 0,
62
+ // ── 输入框绘制(上次视图 / 菜单擦除坐标 / 粘贴与光标回调) ──
63
+ lastView: null,
64
+ lastMenuStartRow: 0, // 上次菜单起始屏行(供擦除)
65
+ lastMenuRows: 0,
66
+ /** 上次画过输入框的 lastView 签名(lines 数 + 每行 display_w 累加);变了说明 prompt.ts 改了文本 → 选区锚点失效,清掉。 */
67
+ lastInputSig: '',
68
+ pasteHandler: null,
69
+ /** 鼠标点击输入框 → prompt.ts(文本 source of truth)提供的光标应用回调;
70
+ * layout 只做"点击 → 算新位置"的映射,实际改 cl/cc 归 prompt(同 pasteHandler 套路)。
71
+ * 若未注册(非输入态/未进 prompt):仅做视觉反馈,cl/cc 不变。 */
72
+ cursorChangeHandler: null,
73
+ // ── 鼠标选区(内容区框选 / 输入框反白 / picker 期禁鼠标) ──
74
+ selection: null,
75
+ selecting: false, // 左键按下中(press→release 之间)
76
+ mouseEnabled: true, // 导航菜单(picker)期间置 false:只吞报表不做选区/滚动,防菜单被 viewport 重画覆盖
77
+ inputSelection: null,
78
+ /** overlay 鼠标接管者(composer 输入面板等全屏弹窗)。非 null 时所有鼠标事件先喂给它,
79
+ * 返回 true = 已消费(不再走 layout 默认处理);弹窗自己消费全部事件,
80
+ * 防止背景选区/翻页触发 repaintViewport 重画覆盖弹窗。传 null 注销。 */
81
+ overlayMouseHandler: null,
82
+ };
@@ -0,0 +1,351 @@
1
+ // L1 状态栏:两行式底栏(spinner 行 + model 行)的拼装与绘制、走时心跳。
2
+ // 从 layout-internal/core.ts 按依赖分层拆出;可变运行态集中在 ./state.ts。
3
+ import { stdout } from 'node:process';
4
+ import { displayWidth, truncateDisplay, ansiDisplayWidth, fmtElapsed, stripAnsi } from '../render.js';
5
+ import { ui } from '../theme.js';
6
+ import * as content from '../content.js';
7
+ import { t } from '../../i18n/index.js';
8
+ import { state } from './state.js';
9
+ import { esc, cup, getGeo, runningCaretPos, isStreamingPaused } from './screen.js';
10
+ const RUNNING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
11
+ // ── 状态行(两行式:spinner 行 + model 行)──
12
+ const STATUS_SEP = ' ';
13
+ const STATUS_SEP_W = STATUS_SEP.length;
14
+ /** 通用两段式排版:左段左端对齐 + 空格填充 + 右段右端对齐。pad 至少 1 空格防粘连。 */
15
+ function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
16
+ const pad = Math.max(1, cols - leftW - rightW);
17
+ return `${leftStr}${' '.repeat(pad)}${rightStr}`;
18
+ }
19
+ /** 上线之上那行(spinner 行):左段 = spinner 帧 + 状态文字 + 走时(全部左对齐,紧跟不分离)。
20
+ * 右段仅在滚动回看时显历史指示(右端对齐)。
21
+ * 示例:
22
+ * INPUT: ● 空闲
23
+ * 思考中: ⠹ 思考中… 0.5s
24
+ * 运行心跳: ⠋ 生成中 0.5s
25
+ * 滚动回看: ● 空闲 历史 ↑3 (PgDn 回底) */
26
+ export function composeSpinnerLine(status, cols) {
27
+ const spinning = state.mode === 'running' && state.runningFrame >= 0;
28
+ const hasSpinner = !!status.spinnerFrame;
29
+ const scrolled = state.scrollOffset > 0;
30
+ // 走时(仅运行态)
31
+ const elapsed = state.mode === 'running' && state.turnStart != null ? fmtElapsed(Date.now() - state.turnStart) : '';
32
+ // ── 左段:帧 + 状态 + 走时,全部紧跟 ──
33
+ let lead;
34
+ let leadW;
35
+ if (scrolled) {
36
+ // 滚动回看:左段 = 符号(● 或 心跳帧) + 状态名 + (运行态)走时;右段 = 历史指示。
37
+ // 跟非回看态严格对齐——RUNNING 态显示走时,INPUT 态不显示,避免滚动时信息降级。
38
+ const symbol = spinning
39
+ ? `${ui.bold}${ui.accent}${RUNNING_FRAMES[state.runningFrame]}${ui.reset}`
40
+ : `${ui.accent}●${ui.reset}`;
41
+ // spinning(运行态流式中 spinner 已停,靠 turnTimer 心跳):同非滚动 spinning 分支加
42
+ // || '生成中' fallback——流式首 token 到即 spinner.stop(),若 stop() 回调未留状态字,
43
+ // 此处兜底不再让 lead 只剩心跳帧字符(用户误读为"左侧蒸发了")。
44
+ const label = spinning ? status.status || '生成中' : status.status;
45
+ const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
46
+ lead = `${symbol} ${ui.dim}${label}${ui.reset}${ePart}`;
47
+ leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
48
+ }
49
+ else if (hasSpinner) {
50
+ // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
51
+ const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
52
+ lead = `${ui.bold}${ui.accent}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
53
+ leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
54
+ }
55
+ else if (spinning) {
56
+ // 运行态心跳帧(流式输出中 / 命令态如 /rollback /compact /resume):帧 + 状态文字(优先)或生成中(兜底) + 走时
57
+ const label = status.status || '生成中';
58
+ const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
59
+ lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[state.runningFrame]}${ui.reset} ${ui.dim}${label}${ui.reset}${ePart}`;
60
+ leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
61
+ }
62
+ else {
63
+ // INPUT 态:● + 状态文字(无走时)
64
+ lead = `${ui.accent}●${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
65
+ leadW = 1 + 1 + displayWidth(status.status);
66
+ }
67
+ // ── 右段:仅滚动回看时显历史指示 ──
68
+ let tail = '';
69
+ let tailW = 0;
70
+ if (scrolled) {
71
+ tail = t('status.history', { count: state.scrollOffset });
72
+ tailW = displayWidth(tail);
73
+ }
74
+ const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
75
+ return twoColumn(lead, leadW, rightStr, tailW, cols);
76
+ }
77
+ /** 下线之下那行(model 行):左 = 模式标识 + 本轮 token chip;右 = context + cwd,右端对齐。
78
+ * 活跃 plan chip 不再放这里,改放 spinner 行上方的「虚拟空行」(contentBottom+1,见 drawStatusBar),
79
+ * 既不挤 model 行,又给输入区上方留出可视分隔带。 */
80
+ export function composeModelLine(status, cols) {
81
+ const ctx = status.contextBar; // 已带色
82
+ const ctxW = ansiDisplayWidth(ctx);
83
+ // 左段:模式标识 + 切换提示(灰)+ 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
84
+ const modeTag = status.modeTag ?? '';
85
+ const modeColor = modeTag === 'Plan' ? ui.yellow : ui.accent;
86
+ const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
87
+ const modeW = modeTag ? displayWidth(modeTag) : 0;
88
+ // 切换提示:告诉用户怎么切模式。灰(dim)降优先级,不与 modeTag 抢色;只在有 modeTag 时出现。
89
+ const HINT = t('status.modeSwitch');
90
+ const hintW = modeTag ? displayWidth(HINT) : 0;
91
+ const hintPart = modeTag ? `${ui.dim}${HINT}${ui.reset}` : '';
92
+ const tokChip = formatTurnTokenChip(status.lastTurnUsage);
93
+ const tokW = displayWidth(stripAnsi(tokChip));
94
+ // 合并左段:段间留 2 空格分隔。极窄时 hint 与 chip 都可能藏掉。
95
+ // 优先级:modeTag(必) > chip(提示累计 token,有信息量)> hint(纯说明性,窄时最先省)。
96
+ const sepMH = modePart && (hintPart || tokChip) ? ' ' : '';
97
+ const sepHT = hintPart && tokChip ? ' ' : '';
98
+ const leftStr = `${modePart}${sepMH}${hintPart}${sepHT}${tokChip}`;
99
+ const leftW = modeW +
100
+ (modePart && (hintPart || tokChip) ? sepMH.length : 0) +
101
+ hintW +
102
+ (hintPart && tokChip ? sepHT.length : 0) +
103
+ tokW;
104
+ // 右段:实时用量 chip(仅 RUNNING)+ ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
105
+ // 实时 chip 放 context 进度条左侧:本轮累计 ↑prompt ↓completion,流式实时增长。
106
+ // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再按 hint→chip 顺序省。
107
+ const liveChip = state.mode === 'running' && state.liveUsage ? formatLiveUsageChip(state.liveUsage) : '';
108
+ const liveW = displayWidth(stripAnsi(liveChip));
109
+ const liveSepW = liveChip ? STATUS_SEP_W : 0;
110
+ const minGap = 2;
111
+ const cwdBudget = cols - leftW - minGap - liveW - liveSepW - ctxW - STATUS_SEP_W - 1;
112
+ const cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
113
+ const cwdW = displayWidth(cwd);
114
+ const rightStr = `${liveChip}${liveChip ? STATUS_SEP : ''}${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
115
+ const rightW = liveW + liveSepW + ctxW + STATUS_SEP_W + cwdW;
116
+ // 极窄:逐步降级——先藏 hint,再藏 token chip,只剩 modeTag 与右段挤。
117
+ // 这样 80 列宽终端下 hint 和 chip 都能稳住,只 <50 列才退化到只剩 modeTag。
118
+ if (leftW + minGap + rightW > cols) {
119
+ // 优先藏 hint(纯说明,信息密度最低):仅留 modePart + sep + tokChip
120
+ if (modePart && tokChip && hintPart) {
121
+ const leftStr2 = `${modePart}${sepMH}${tokChip}`;
122
+ const leftW2 = modeW + sepMH.length + tokW;
123
+ if (leftW2 + minGap + rightW <= cols) {
124
+ return twoColumn(leftStr2, leftW2, rightStr, rightW, cols);
125
+ }
126
+ }
127
+ // 再藏 token chip:仅留 modePart
128
+ if (modePart && tokChip) {
129
+ return twoColumn(modePart, modeW, rightStr, rightW, cols);
130
+ }
131
+ }
132
+ return twoColumn(leftStr, leftW, rightStr, rightW, cols);
133
+ }
134
+ /** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。
135
+ * 显示策略:chip 信息密度有限,只显示总量 + 一个 ↻ 标记表示有 cache 命中。
136
+ * 详细分项(↑↓ 计费/↻ 缓存/reasoning)在 turn 末 summary 行展示,不在此处展开。 */
137
+ function formatTurnTokenChip(usage) {
138
+ if (!usage || !usage.totalTokens)
139
+ return '';
140
+ const n = usage.totalTokens;
141
+ const text = n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
142
+ const cached = usage.cachedTokens ?? 0;
143
+ const cacheTag = cached > 0 ? ` ↻ ${cached < 1000 ? cached : `${(cached / 1000).toFixed(cached >= 10000 ? 0 : 1)}k`}` : '';
144
+ // chip 用 mid 灰(降优先级)— 模式仍是主色
145
+ return `${ui.dim}${text} tokens${cacheTag}${ui.reset}`;
146
+ }
147
+ /** 运行态实时用量 chip(放 context 进度条左侧):↑计费prompt(裸-cached,与轮末摘要 (↑…) 同口径)
148
+ * ↓completion,cache 命中带 ↻ 标记;缩写与轮末摘要 / 左侧 turn chip 同款。
149
+ * 流式期为估算值,每步末尾 usage chunk 到达后换实测;dim 灰降优先级,不与进度条阈值警示抢色。 */
150
+ function formatLiveUsageChip(u) {
151
+ const fmt = (n) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`);
152
+ const cached = u.cachedTokens ?? 0;
153
+ const billable = Math.max(0, u.promptTokens - cached);
154
+ const cacheTag = cached > 0 ? ` ↻ ${fmt(cached)}` : '';
155
+ return `${ui.dim}↑ ${fmt(billable)} ↓ ${fmt(u.completionTokens)}${cacheTag}${ui.reset}`;
156
+ }
157
+ /** spinner 行上方的「虚拟空行」(contentBottom+1)。
158
+ * - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow)
159
+ * - 无活跃 plan:空(保留原分隔视觉,避免内容贴输入区)
160
+ * - 过长(> cols):始终**截断保 1 行 + "…"**,不撑高脚栏、不拆 2 行——
161
+ * 步进式任务标题/current step 通常是自然语言,即使切「 ▸ 」拆 2 行,第 2 行也大概率仍超长
162
+ * (实测「第三步:实现核心业务模块。…」本身就比 cols 长),拆完依然覆盖 spinner/输入区,得不偿失。
163
+ * 截断 + "…" 一行内永远不溢出,脚栏恒 6 行,spinner/输入/下线/model 行偏移稳定。
164
+ * 这行在 DECSTBM 滚动区外([1, contentBottom]),稳定不滚。 */
165
+ export function composePlanLines(status, cols) {
166
+ const plan = (status.planSummary ?? '').trim();
167
+ if (!plan) {
168
+ state.planRows = 1;
169
+ return [];
170
+ }
171
+ const w = displayWidth(stripAnsi(plan));
172
+ if (w <= cols) {
173
+ state.planRows = 1;
174
+ return [`${ui.yellow}${plan}${ui.reset}`];
175
+ }
176
+ // 溢出:截断为 1 行 + "…" 后缀(留 1 列空间给省略号)。
177
+ state.planRows = 1;
178
+ return [`${ui.yellow}${truncateDisplay(plan, Math.max(1, cols - 1))}…${ui.reset}`];
179
+ }
180
+ /** 画状态行(plan 行 + spinner 行 + model 行,三行)。RUNNING 态 spinner 频繁调。
181
+ * 行号(footerH=6 当 plan 单行;footerH=7 当 plan 撑 2 行):
182
+ * plan 行 = contentBottom+1 (单行)
183
+ * plan 第 2 行 = contentBottom+2 (双行,可选)
184
+ * spinner 行 = contentBottom+1+planRows (● 空闲 / ⠹ 思考中… / etc)
185
+ * 上线 = contentBottom+2+planRows (画在 paintInput)
186
+ * 输入行 = contentBottom+3+planRows
187
+ * 下线 = contentBottom+4+planRows
188
+ * model 行 = rows (屏底:auto + ctx + cwd) */
189
+ export function drawStatusBar(status) {
190
+ if (!state.active || !state.base)
191
+ return;
192
+ const s = status ?? { ...state.base, status: state.statusText, spinnerFrame: state.spinnerFrame };
193
+ const g = getGeo();
194
+ const planLines = composePlanLines(s, g.cols);
195
+ const planRow1 = g.contentBottom + 1;
196
+ const spinnerRow = g.contentBottom + 1 + state.planRows;
197
+ const modelRow = g.rows; // 屏底:model 行
198
+ // plan 可能占 1 或 2 行(过长自动撑开);spinner/model 行随之平移。
199
+ // 一次写入:plan 1/2 行 + spinner 行 + model 行,末尾 cup 回续写位/输入框光标。
200
+ let planBuf = '';
201
+ for (let i = 0; i < planLines.length; i++) {
202
+ planBuf += cup(planRow1 + i, 1) + esc.clearLine + planLines[i];
203
+ }
204
+ // 计划行从 2 行降到 1 行时,清掉残留的第 2 行(撑高后回退不留尾巴)。
205
+ if (state.planRows === 1 && g.footerH > 6) {
206
+ planBuf += cup(planRow1 + 1, 1) + esc.clearLine;
207
+ }
208
+ // plan 从非空变成空(已结算为 ## Done:/ notes 里无活跃 ## Plan: 段)时,清掉残留的 plan 行 1。
209
+ // 否则上轮渲染的「plan: 标题 (N/M) ▸ 当前步」会卡在底栏「虚拟空行」位置直到下一次重启 REPL。
210
+ if (planLines.length === 0) {
211
+ planBuf += cup(planRow1, 1) + esc.clearLine;
212
+ }
213
+ let out = planBuf +
214
+ cup(spinnerRow, 1) +
215
+ esc.clearLine +
216
+ composeSpinnerLine(s, g.cols) +
217
+ cup(modelRow, 1) +
218
+ esc.clearLine +
219
+ composeModelLine(s, g.cols);
220
+ if (state.mode === 'running') {
221
+ // 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
222
+ const p = runningCaretPos();
223
+ out += cup(p.row, p.col);
224
+ }
225
+ else {
226
+ out += cup(state.scrollOffset === 0 ? state.contentRow : g.contentBottom, state.scrollOffset === 0 ? state.contentCol : 1);
227
+ }
228
+ stdout.write(out);
229
+ }
230
+ /** 更新易变状态(状态文字 + spinner 帧)并重画状态行。spinner / agent 用。 */
231
+ export function setStatus(status, frame) {
232
+ state.statusText = status;
233
+ state.spinnerFrame = frame;
234
+ drawStatusBar();
235
+ }
236
+ /**
237
+ * 启走时刷新计时器:RUNNING 态每 80ms 重画状态行,使 composeStatus 重算 elapsed。
238
+ * 必要性:spinner 在首 token 到达即 stop,思考/正文流式期间状态行不再经 spinner 刷新;
239
+ * 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 同速,重叠幂等无妨。
240
+ * 非 TTY 不启(active=false 时 drawStatusBar 为 no-op)。
241
+ */
242
+ export function startTurnTimer() {
243
+ if (!state.active)
244
+ return;
245
+ stopTurnTimer();
246
+ state.runningFrame = 0; // 进入运行态:启动状态行 chip 心跳(首帧立即生效)
247
+ state.turnTimer = setInterval(() => {
248
+ state.runningFrame = (state.runningFrame + 1) % RUNNING_FRAMES.length; // 推进心跳帧,让前导符跳动
249
+ drawStatusBar();
250
+ }, 80);
251
+ state.turnTimer.unref();
252
+ }
253
+ /** 停走时计时器。enterInputMode / exitAltScreen 调;intervention 面板进入时也调(防 drawStatusBar 80ms 心跳把光标拉到 runningCaretPos,覆盖 paintInput 的正确光标位)。 */
254
+ export function stopTurnTimer() {
255
+ if (state.turnTimer) {
256
+ clearInterval(state.turnTimer);
257
+ state.turnTimer = null;
258
+ }
259
+ }
260
+ /** 恢复走时计时器(若 RUNNING 态)。intervention 面板退出时调,恢复状态行走时心跳。 */
261
+ export function startTurnTimerIfRunning() {
262
+ if (state.active && state.mode === 'running')
263
+ startTurnTimer();
264
+ }
265
+ /**
266
+ * 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
267
+ * 仅 TTY + offset=0(实时尾)+ 非打字暂停态时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
268
+ * 配合 clearLiveAtCursor 在 spinner 停时清掉,随后 contentWrite 的结果即写在该行(spinner 不入历史缓冲)。
269
+ *
270
+ * 记 frameRow/frameCol = 实际画帧位置;clearLiveAtCursor 清"这行"而非当前续写位,防续写位漂移时清错行。
271
+ * 续写位漂移时(运行期间被某次 contentWrite 推进,罕见但 557e678 后可能)先清旧 frameRow 再画新位,避免旧帧残留。
272
+ * 打字暂停态(isStreamingPaused)spinner 不画帧——恢复 557e678 前的 spinner 隐形行为(只关 spinner,不动 contentWrite)。
273
+ */
274
+ export function paintLiveAtCursor(text) {
275
+ if (!state.active || !ui.isTTY || state.scrollOffset !== 0 || isStreamingPaused()) {
276
+ // 滚动态 / 暂停态:不画新帧。但若有旧帧残留(frameRow),必须清掉——
277
+ // 否则旧 spinner 帧停在历史视图某行,用户看到「思考中」卡在消息堆里(根因)。
278
+ // 滚动态由状态行心跳兜底显示运行状态,不需内联帧;清完后 frameRow 归零,回尾时下一帧自然重画。
279
+ if (state.frameRow) {
280
+ // resize 后 contentBottom 可能缩小:清旧帧时钳 frameRow 到当前可视区,避免 cup 到屏外行
281
+ const g = getGeo();
282
+ const fr = Math.min(state.frameRow, g.contentBottom);
283
+ let out = cup(fr, state.frameCol) + esc.clearLine;
284
+ if (state.mode === 'running') {
285
+ const p = runningCaretPos();
286
+ out += cup(p.row, p.col);
287
+ }
288
+ stdout.write(out);
289
+ state.frameRow = 0;
290
+ state.frameCol = 0;
291
+ }
292
+ return;
293
+ }
294
+ // resize 后 contentRow 可能过时(拖终端框):
295
+ // - 缩小:contentRow > 新 bottom → 钳到新 bottom
296
+ // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total+1, bottom)(内容末尾或新区底)
297
+ // total+1 是合法「待写位」(breakRow 后光标在新空行);用 total 会把续写位拉回最后一行,
298
+ // 首次 contentWrite 覆盖 banner(首条消息「插到 logo 下面」bug)。
299
+ // 否则 cup 到旧行号→帧画在屏幕中间(旧 bottom 位置),而非内容末尾/最底部。
300
+ const g = getGeo();
301
+ const committed = content.committedRows();
302
+ if (state.contentRow > g.contentBottom)
303
+ state.contentRow = g.contentBottom;
304
+ if (state.scrollOffset === 0 && state.contentRow < g.contentBottom) {
305
+ // 用 committed+1 而非 total+1:breakRow 后 hasCurrent=true,totalRows 已含当前空行,+1 会多跳一行
306
+ // (contentWrite 入口的同类逻辑已同步修正,见上方注释)。
307
+ state.contentRow = Math.min(committed + 1, g.contentBottom);
308
+ }
309
+ let out = '';
310
+ if (state.frameRow && (state.frameRow !== state.contentRow || state.frameCol !== state.contentCol)) {
311
+ // 清旧帧同样钳到可视区(resize 后 frameRow 可能 > contentBottom)
312
+ out += cup(Math.min(state.frameRow, g.contentBottom), state.frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
313
+ }
314
+ out += cup(state.contentRow, state.contentCol) + esc.clearLine + text;
315
+ if (state.mode === 'running') {
316
+ const p = runningCaretPos();
317
+ out += cup(p.row, p.col);
318
+ }
319
+ stdout.write(out);
320
+ state.frameRow = state.contentRow;
321
+ state.frameCol = state.contentCol;
322
+ }
323
+ /** 清掉 paintLiveAtCursor 画过的那行瞬时活动文本。清"实际画过的位置"(frameRow),非当前续写位。
324
+ * 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。
325
+ * 滚动态(scrollOffset≠0)也必须清残留帧——否则旧 spinner 帧卡在历史视图某行(「思考中在消息堆里」根因)。 */
326
+ export function clearLiveAtCursor() {
327
+ if (!state.active || !ui.isTTY)
328
+ return;
329
+ if (!state.frameRow)
330
+ return; // 没画过就不清(避免误清当前续写位内容)
331
+ // 滚动态也清:旧帧不该残留。清"画过的行"(frameRow),回尾后该行由 repaintViewport 重画历史内容。
332
+ // resize 后 contentBottom 可能缩小:frameRow 钳到可视区,避免 cup 到屏外行清错位置。
333
+ const g = getGeo();
334
+ let out = cup(Math.min(state.frameRow, g.contentBottom), state.frameCol) + esc.clearLine;
335
+ if (state.mode === 'running') {
336
+ const p = runningCaretPos();
337
+ out += cup(p.row, p.col);
338
+ }
339
+ stdout.write(out);
340
+ state.frameRow = 0;
341
+ state.frameCol = 0;
342
+ }
343
+ /** 推送 / 清空运行态实时 token 用量(agent core 流式推送;repl 轮末清 undefined)。
344
+ * 不触发重画:RUNNING 态 turnTimer 80ms 心跳重画状态行,自然取到最新值。 */
345
+ export function setLiveUsage(u) {
346
+ state.liveUsage = u;
347
+ }
348
+ /** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token chip)。repl 在轮次边界与切模式时调。 */
349
+ export function setStatusBase(b) {
350
+ state.base = b;
351
+ }
@@ -0,0 +1 @@
1
+ export {};