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,365 @@
1
+ // L2 输入框绘制:开窗 / 折行 / 反白 / dim 占位,以及 repaint 总刷新。
2
+ // 从 layout-internal/core.ts 按依赖分层拆出;可变运行态集中在 ./state.ts。
3
+ import { stdout } from 'node:process';
4
+ import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, wrapByDisplayWidth } from '../render.js';
5
+ import { ui } from '../theme.js';
6
+ import * as content from '../content.js';
7
+ import { state } from './state.js';
8
+ import { esc, cup, getGeo, setRegion, runningCaretPos } from './screen.js';
9
+ import { SEL_OPEN, SEL_OFF, normalizeInputSelection, inputViewSig } from './selection.js';
10
+ import { composeSpinnerLine, composeModelLine, composePlanLines, drawStatusBar } from './statusbar.js';
11
+ // ── 输入区(底栏输入框 + 向上菜单)──
12
+ /**
13
+ * 把逻辑行软折成可视行并开窗(超 maxInputRows 时滑窗保光标可见)。返回可见可视行文本(已折行)+
14
+ * 光标在窗口内行号 + 光标在该可视行的显示列 + 所需输入行数 + 窗口起始全局可视行号。
15
+ *
16
+ * 光标可视位置按各行**实际**显示宽度逐行累加(不按 width×行号 除法):宽字符(CJK=2)在行尾放不下时
17
+ * 整字折到下行、本行留尾空格,简单除法会把光标算偏;逐行累加 displayWidth 才与终端实际光标一致。
18
+ */
19
+ function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
20
+ const W = Math.max(1, cols - promptW);
21
+ const lineVis = lines.map((l) => wrapByDisplayWidth(l, W));
22
+ const flat = [];
23
+ for (const lv of lineVis)
24
+ for (const r of lv)
25
+ flat.push(r);
26
+ const totalVis = flat.length;
27
+ // 光标在其逻辑行内的可视行 / 可视列
28
+ const clRows = lineVis[cursorLine] ?? [''];
29
+ let curVisRow = 0;
30
+ let curVisCol = cursorCol;
31
+ {
32
+ let acc = 0;
33
+ for (let i = 0; i < clRows.length; i++) {
34
+ const rw = displayWidth(clRows[i]);
35
+ if (cursorCol <= acc + rw) {
36
+ curVisRow = i;
37
+ curVisCol = cursorCol - acc;
38
+ break;
39
+ }
40
+ acc += rw;
41
+ curVisRow = i;
42
+ curVisCol = cursorCol - acc;
43
+ }
44
+ }
45
+ // 光标绝对可视行
46
+ let curAbs = 0;
47
+ for (let i = 0; i < cursorLine; i++)
48
+ curAbs += lineVis[i].length;
49
+ curAbs += curVisRow;
50
+ const maxInputRows = Math.max(1, Math.floor(rows * 0.4));
51
+ let startVis = 0;
52
+ if (totalVis > maxInputRows) {
53
+ startVis = Math.max(0, Math.min(curAbs - maxInputRows + 1, totalVis - maxInputRows));
54
+ }
55
+ const showCount = Math.min(maxInputRows, totalVis);
56
+ return {
57
+ visRows: flat.slice(startVis, startVis + showCount),
58
+ visLine: curAbs - startVis,
59
+ cursorVisCol: curVisCol,
60
+ inputRows: showCount,
61
+ startVis,
62
+ };
63
+ }
64
+ /** 把 lines 按 W 做软折,返回 lineVis;供 paintInput 高亮预算用。与 windowInputVis 复用 wrap 算法。 */
65
+ function visInputLines(lines, W) {
66
+ return lines.map((l) => wrapByDisplayWidth(l, W));
67
+ }
68
+ /**
69
+ * 给纯文本(无 ANSI,来自 wrap 后的可视段)做"显示列区间"高亮,返回插入 SEL_OPEN/SEL_OFF 后的字符串。
70
+ * 输入字段 vis 段宽 = displayWidth(seg),clipStart/clipEnd 单位 = display_col。
71
+ * 与 highlightRange 等价语义但省去 ANSI split(纯文本无 SGR),更省。
72
+ */
73
+ function highlightWithinRow(seg, clipStart, clipEnd) {
74
+ if (clipEnd <= clipStart)
75
+ return seg;
76
+ let out = '';
77
+ let w = 0;
78
+ let opened = false;
79
+ for (const ch of seg) {
80
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
81
+ if (!opened && w >= clipStart && w < clipEnd) {
82
+ out += SEL_OPEN;
83
+ opened = true;
84
+ }
85
+ if (opened && w >= clipEnd) {
86
+ out += SEL_OFF;
87
+ opened = false;
88
+ }
89
+ out += ch;
90
+ w += cw;
91
+ }
92
+ if (opened)
93
+ out += SEL_OFF;
94
+ return out;
95
+ }
96
+ /**
97
+ * 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
98
+ * prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
99
+ */
100
+ export function paintInput(view) {
101
+ if (!state.active || !state.base)
102
+ return;
103
+ state.lastView = view;
104
+ // 失效检测:lastView 文本若有变化(键输入/AI 续写)→ 选区锚点不再有效,清掉避免「错位反白」。
105
+ // 内容区选区用 content 自己的锚,与 lastView 无关,不动。
106
+ if (state.inputSelection) {
107
+ const sig = inputViewSig(view);
108
+ if (sig !== state.lastInputSig) {
109
+ state.inputSelection = null;
110
+ }
111
+ state.lastInputSig = sig;
112
+ }
113
+ else {
114
+ state.lastInputSig = inputViewSig(view);
115
+ }
116
+ const preGeo = getGeo();
117
+ // 累积本帧所有 cup/clearLine/文本,末尾一次写出——避免「擦旧菜单」与「重画」分多次 write 时,
118
+ // 终端把中间空白渲染出来 → 菜单/面板切换时闪烁(↑↓ 导航每次 redraw 都全帧擦后重画)。
119
+ let buf = '';
120
+ // 1. 擦旧菜单(用上次记录的起始行 + 行数,与本次几何无关——setRegion 不动屏幕内容)
121
+ if (state.lastMenuRows > 0) {
122
+ for (let i = 0; i < state.lastMenuRows; i++) {
123
+ buf += cup(state.lastMenuStartRow + i, 1) + esc.clearLine;
124
+ }
125
+ state.lastMenuRows = 0;
126
+ }
127
+ // 2. 算输入可视行(软折行)+ 必要时 setRegion。dim=运行态占位:单行不折行。
128
+ const promptW = displayWidth(view.prompt);
129
+ const vis = view.dim
130
+ ? {
131
+ visRows: [view.lines[0] ?? ''],
132
+ visLine: 0,
133
+ cursorVisCol: 0,
134
+ inputRows: 1,
135
+ startVis: 0,
136
+ }
137
+ : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
138
+ const needFooterH = 5 + vis.inputRows + (state.planRows - 1); // 1 虚拟空 + 1 spinner 行 + 1 上线 + 输入行 + 1 下线 + 1 model 行 + plan 多出的行数
139
+ let g = preGeo;
140
+ if (needFooterH !== state.footerH) {
141
+ // setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
142
+ // lastMenuStartRow,须在 setRegion 改区域前落地),再 setRegion,之后 2b..6 重新累积。
143
+ // footerH 不变时(常见:单行输入 / 菜单切换 / ↑↓ 导航)整帧一次 write,终端原子应用,无中间空白→不闪烁。
144
+ if (buf) {
145
+ stdout.write(buf);
146
+ buf = '';
147
+ }
148
+ g = setRegion(needFooterH);
149
+ }
150
+ // 2b. 重画内容末行(底栏上一行)从缓冲:防 WT 边距漏影(状态行重复到该行),并保证该行显正确内容
151
+ {
152
+ const slice = content.sliceFromEnd(state.scrollOffset, g.contentBottom);
153
+ const line = slice[g.contentBottom - 1] ?? '';
154
+ buf += cup(g.contentBottom, 1) + esc.clearLine + line;
155
+ }
156
+ // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):
157
+ // - 无活跃 plan:清空保留作分隔(原设计)
158
+ // - 有活跃 plan:渲染 plan chip(整帧重画时也要更新,避免 listener 漏触发后残留)
159
+ // - plan 过长:占 2 行(planRows=2),spinner/上线/输入行随之整体下移 1 行
160
+ // paintInput 与 drawStatusBar 共享同一 planRows 模块态,setRegion 已据此把脚栏撑高。
161
+ {
162
+ const planBuf = composePlanLines(state.base, preGeo.cols);
163
+ for (let i = 0; i < planBuf.length; i++) {
164
+ buf += cup(g.contentBottom + 1 + i, 1) + esc.clearLine + planBuf[i];
165
+ }
166
+ // 计划行从 2 行降到 1 行(plan 当前很短):清掉残留的第 2 行,不留尾巴。
167
+ if (planBuf.length < state.planRows && state.planRows === 2) {
168
+ buf += cup(g.contentBottom + 2, 1) + esc.clearLine;
169
+ state.planRows = 1;
170
+ }
171
+ // plan 从非空变成空(已结算/无活跃段)时,清掉残留的 plan 行 1。
172
+ // 上面 for 循环在 planBuf.length===0 时不写任何 cup,需要显式清一行回到「虚拟空行」。
173
+ if (planBuf.length === 0) {
174
+ buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
175
+ }
176
+ }
177
+ // 3. 状态行:spinner 行 + model 行(两行式底栏)
178
+ const spinnerRow = g.contentBottom + 1 + state.planRows; // plan 占 planRows 行(1 或 2),spinner 紧跟其后
179
+ const modelRow = g.rows; // 屏底:model 行
180
+ const status = { ...state.base, status: state.statusText, spinnerFrame: state.spinnerFrame };
181
+ buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
182
+ buf += cup(modelRow, 1) + esc.clearLine + composeModelLine(status, g.cols);
183
+ // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
184
+ buf += cup(g.contentBottom + 2 + state.planRows, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
185
+ // 4. 输入行(g.contentBottom+3+planRows .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
186
+ const firstInputRow = g.contentBottom + 3 + state.planRows;
187
+ const inputRowsAvail = vis.inputRows; // 脚栏总高 - 1(spinner) - 1(上 sep) - 1(下 sep) - 1(model) - planRows
188
+ const indent = ' '.repeat(promptW);
189
+ // 光标:不画反白块/假光标——输入框走终端真光标(WT / VSCode 终端默认竖线/闪烁块,
190
+ // 各终端表现略不同但都贴合 IME 候选气泡且不再"挡住字符")。真光标位置由下方第 6 步 cup 写。
191
+ // 保留 view.caret=false 路径给 picker 等非文本输入:把真光标定位到 hint 末尾。
192
+ const inpSel = normalizeInputSelection(); // 已签名检测失效,这里看到的要么有效要么 null
193
+ // 预算 (visRow -> line) 用于高亮:与 inputScreenToInputPos 的反推同源(flat → line + segInLine)
194
+ const colW = Math.max(1, preGeo.cols - promptW);
195
+ const lineVisArr = visInputLines(view.lines, colW);
196
+ const flatInput = [];
197
+ for (const lv of lineVisArr)
198
+ for (const r of lv)
199
+ flatInput.push(r);
200
+ const flatToLine = [];
201
+ for (let li = 0; li < lineVisArr.length; li++) {
202
+ const segs = lineVisArr[li];
203
+ let acc = 0;
204
+ for (let s = 0; s < segs.length; s++) {
205
+ flatToLine.push({ line: li, segInLine: s, inLineDisplayCol: acc });
206
+ acc += displayWidth(segs[s]);
207
+ }
208
+ if (segs.length === 0)
209
+ flatToLine.push({ line: li, segInLine: 0, inLineDisplayCol: 0 });
210
+ }
211
+ for (let i = 0; i < inputRowsAvail; i++) {
212
+ const line = vis.visRows[i] ?? '';
213
+ const r = firstInputRow + i;
214
+ const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
215
+ let text;
216
+ if (view.dim) {
217
+ text = renderDimInputRow(view.prompt, line, view.placeholder ?? '', g.cols);
218
+ }
219
+ else {
220
+ text = `${prefix}${line}`;
221
+ // 空输入引导:INPUT 态缓冲为空时,在首行画 dim ghost 占位(「输入 / 查看命令…」);
222
+ // 任何按键都会改变缓冲 → 下一帧 isEmpty=false → 占位自然消失,无需额外擦除逻辑。
223
+ if (!line && view.placeholder && vis.startVis === 0 && i === 0) {
224
+ const avail = Math.max(0, preGeo.cols - promptW);
225
+ text = `${prefix}${ui.dim}${truncateDisplay(view.placeholder, avail)}${ui.reset}`;
226
+ }
227
+ // 输入框反白叠层:仅当 paintInput 不是 dim(运行态 typeahead 不参与选区,避免干扰 IME 气泡),
228
+ // 且该可视段所属的逻辑行落在 inpSel 区间内 → 高亮行内 [colStart,colEnd) 段。
229
+ if (inpSel) {
230
+ const flatIdx = vis.startVis + i;
231
+ const m = flatIdx < flatToLine.length ? flatToLine[flatIdx] : flatToLine[flatToLine.length - 1];
232
+ if (m.line >= inpSel.startLine && m.line <= inpSel.endLine) {
233
+ const seg = flatInput[flatIdx] ?? '';
234
+ const segW = displayWidth(seg);
235
+ const colStart = m.line === inpSel.startLine ? inpSel.startCol - m.inLineDisplayCol : 0;
236
+ const colEnd = m.line === inpSel.endLine ? inpSel.endCol - m.inLineDisplayCol : segW;
237
+ if (colStart < segW && colEnd > colStart) {
238
+ // 对 seg(纯文本无 ANSI)做行内高亮:在 [clipStart, clipEnd) 之间插 SEL_OPEN/SEL_OFF。
239
+ text = `${prefix}${highlightWithinRow(seg, Math.max(0, colStart), Math.min(segW, colEnd))}`;
240
+ }
241
+ }
242
+ }
243
+ }
244
+ buf += cup(r, 1) + esc.clearLine + text;
245
+ }
246
+ // 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
247
+ buf += cup(g.rows - 1, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
248
+ // 5. 向上菜单(画在内容区底,底栏正上方)
249
+ if (view.menu && view.menu.lines.length > 0) {
250
+ const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
251
+ const menuStart = g.contentBottom - menuRows + 1;
252
+ for (let i = 0; i < menuRows; i++) {
253
+ buf += cup(menuStart + i, 1) + esc.clearLine + view.menu.lines[i];
254
+ }
255
+ state.lastMenuStartRow = menuStart;
256
+ state.lastMenuRows = menuRows;
257
+ }
258
+ // 6. 光标
259
+ if (view.dim) {
260
+ // 运行态(回尾 / 滚动回看均):真光标归输入框光标位(供 IME 锚定,气泡在输入框而非内容区)。
261
+ // 滚动态也归输入框——上滑看历史时打字,IME 候选气泡须锚到输入框(conhost IME 不跟随 cup 后续移动,
262
+ // 须让打字前光标已在输入框);viewport 锁历史靠 scrollOffset,不靠光标位置。
263
+ const p = runningCaretPos();
264
+ buf += cup(p.row, p.col);
265
+ }
266
+ else {
267
+ const r = firstInputRow + vis.visLine;
268
+ const col = promptW + vis.cursorVisCol + 1;
269
+ buf += cup(r, col);
270
+ }
271
+ if (buf)
272
+ stdout.write(buf); // 整帧一次写出(footerH 不变时):终端原子应用,无中间空白→不闪烁
273
+ }
274
+ /**
275
+ * 渲染运行态输入行(dim):❯ + (有打字:dim 文本 + 末尾反白块状光标;无打字:反白光标在起点 + dim 占位 ghost)。
276
+ * 光标是「画出来」的反白块(非真终端光标)——真光标仍归续写位,避开与 contentWrite/drawStatusBar 争用;
277
+ * 输入行在 RUNNING 期间只被按键回显重画(流式/spinner 不碰这行),故画上去的光标能稳住。
278
+ * 内容截断到 cols-promptW-1(留 ❯ 与光标各 1 cell),防超长软折行写穿下线。
279
+ */
280
+ function renderDimInputRow(prompt, text, placeholder, cols) {
281
+ const promptW = displayWidth(prompt);
282
+ // dim 态(运行中打字):不画反白块——反白块视觉占位是 INPUT 态真光标的旧版,这里真光标本
283
+ // 就被隐起来(IME 锚定需要,见 contentMode),在 dim 文本后硬塞个反白空格会"白块闪烁"。
284
+ // 用户读 dim 文本本身就能定位打字边界;真光标在 INPUT 态显形(竖线/闪烁块,跟终端默认一致)。
285
+ const contentW = Math.max(0, cols - promptW);
286
+ if (text.length > 0) {
287
+ // 有打字:❯ dim + 文本(dim,超长从头部截断保留尾部——光标恒在末尾,须始终看到刚打的字)
288
+ return `${ui.dim}${prompt}${truncateDisplayHead(text, contentW)}${ui.reset}`;
289
+ }
290
+ // 空:❯ dim + dim 占位 ghost(taking the full row)
291
+ const p = placeholder ? truncateDisplay(placeholder, contentW) : '';
292
+ return `${ui.dim}${prompt}${p}${ui.reset}`;
293
+ }
294
+ /**
295
+ * 单行运行态滑窗:以光标为中心,向左右扩展填满 contentW-1(留 1 cell 给光标),
296
+ * 返回可见子串与光标在子串内的显示列。保证光标恒可见,且不软折行(运行态输入框恒单行,
297
+ * 不触发 setRegion/ED,避免流式期间底栏抖动)。
298
+ */
299
+ function windowSingleLine(text, cursor, contentW) {
300
+ const n = text.length;
301
+ if (n === 0)
302
+ return { shown: '', curDisp: 0 };
303
+ const totalDisp = displayWidth(text);
304
+ if (totalDisp <= contentW)
305
+ return { shown: text, curDisp: displayWidth(text.slice(0, cursor)) };
306
+ let i = cursor;
307
+ let j = cursor;
308
+ let w = 0;
309
+ const cw = (idx) => charWidth(text.codePointAt(idx) ?? 0);
310
+ while (i > 0 && w + cw(i - 1) <= contentW - 1) {
311
+ w += cw(i - 1);
312
+ i--;
313
+ }
314
+ while (j < n && w + cw(j) <= contentW - 1) {
315
+ w += cw(j);
316
+ j++;
317
+ }
318
+ return { shown: text.slice(i, j), curDisp: displayWidth(text.slice(i, cursor)) };
319
+ }
320
+ /**
321
+ * 运行态 typeahead 回显:定向写输入行(底栏输入框),非 dim(与空闲态同色)、光标可任意位置、单行。
322
+ * 只 cup 输入行 + clearLine + 文本 + cup 真光标到编辑位——不调 setRegion(运行中禁多行,避免 DECSTBM 抖动)、
323
+ * 不重画状态行/contentBottom、不用 ED。同步 lastView 为运行视图(非 dim),使 scrollBy/resize 的 repaint
324
+ * 仍显当前回显 + 光标。空且带 placeholder 时显 dim ghost(提示 agent 状态;非用户输入,不违反"打字不变灰")。
325
+ * 选区高亮由 paintInput 路径(paint 时鼠标拖选)承担,此处轻量不重复实现。
326
+ */
327
+ export function paintRunningInput(text, cursor, placeholder) {
328
+ if (!state.active || !state.base)
329
+ return;
330
+ const g = getGeo();
331
+ const inputRow = g.contentBottom + 3 + state.planRows; // 运行态 footerH 随 plan 行数动态(6 或 7)
332
+ const promptW = displayWidth('❯ ');
333
+ const contentW = Math.max(1, g.cols - promptW);
334
+ let outLine;
335
+ let curCol; // 输入框内的显示列(不含 prompt)
336
+ if (text.length === 0 && placeholder) {
337
+ outLine = `${ui.dim}❯ ${truncateDisplay(placeholder, contentW)}${ui.reset}`;
338
+ curCol = 0;
339
+ }
340
+ else {
341
+ const { shown, curDisp } = windowSingleLine(text, cursor, contentW);
342
+ outLine = `❯ ${shown}`; // 正常色 —— 运行态打字与空闲态一致
343
+ curCol = curDisp;
344
+ }
345
+ state.lastView = {
346
+ prompt: '❯ ',
347
+ lines: [text],
348
+ placeholder,
349
+ cursorLine: 0,
350
+ cursorCol: cursor,
351
+ menu: null,
352
+ // 不置 dim:运行态输入框与空闲态同色、可任意位置编辑
353
+ };
354
+ const cursorCol = Math.min(g.cols, promptW + curCol + 1);
355
+ stdout.write(cup(inputRow, 1) + esc.clearLine + outLine + cup(inputRow, cursorCol));
356
+ }
357
+ /** 重画当前视图(resize / 内部用)。 */
358
+ export function repaint() {
359
+ if (!state.active || !state.base)
360
+ return;
361
+ if (state.lastView)
362
+ paintInput(state.lastView);
363
+ else
364
+ drawStatusBar();
365
+ }
@@ -0,0 +1,128 @@
1
+ // L0 屏幕原语:ANSI 序列表 / CUP / 几何 / 滚动区域 / 续写位归位。
2
+ // 从 layout-internal/core.ts 按依赖分层拆出;可变运行态集中在 ./state.ts。
3
+ import { stdout } from 'node:process';
4
+ import { displayWidth, truncateDisplayHead } from '../render.js';
5
+ import { state } from './state.js';
6
+ /**
7
+ * 全屏 TUI 布局:alt screen + 单滚动区域(内容区)+ 区域外固定底栏(状态行 + 输入框)。
8
+ *
9
+ * 只依赖最成熟、Windows(WT / conhost)最稳的 ANSI 子集:
10
+ * - alt screen \x1B[?1049h / \x1B[?1049l
11
+ * - DECSTBM 滚动区域 \x1B[<top>;<bottom>r (区域内写满自动在区域内滚动,区域外底栏不被顶)
12
+ * - 逐行擦除 \x1B[2K / \x1B[K (菜单 / 清屏,绝不用 ED \x1B[J 做边界擦除)
13
+ * - CUP 绝对定位 \x1B[<row>;<col>H
14
+ * 不用原点模式(\x1B[?6h)、不依赖 ED 边界、不用 DECSC/DECRC、不用 IL/DL/SU/SD——这些在 conhost 上不可靠。
15
+ *
16
+ * 续写位(contentRow / contentCol)由 contentWrite 按字符宽度 + 折行模拟跟踪;CUP 到它即可随时回到内容续写位,
17
+ * 故底栏刷新(状态行 / spinner)无需保存/恢复光标——CUP 回续写位即可。
18
+ *
19
+ * 关键洞察:RUNNING 态也支持 typeahead 输入与滚动回看。INPUT 态光标常驻输入框;RUNNING 态光标在续写位,
20
+ * 运行中打的字经 paintRunningInput(非 dim)回显(占位行换成已打文本,光标停在编辑位,不与流式争用);
21
+ * 运行中可滚动回看——contentWrite 在 scrollOffset>0 时只喂缓冲不物理写(否则新流式覆盖 viewport 历史行),
22
+ * 回尾(scrollOffset===0)才 cup 续写位写出。两态切换不重设区域(底栏高度恒 = 2),仅多行输入换行撑高时才 setRegion。
23
+ *
24
+ * 非 TTY:全部空操作,contentWrite 退化为 stdout.write,与改造前内联行为一致。
25
+ */
26
+ // 可变运行态全部集中在 ./state.ts 的共享单例;本文件只留纯常量与函数。
27
+ /** 是否处于全屏 TUI(alt screen)激活态。非 TTY / 嵌入宿主(host)下为 false。
28
+ * 子 agent 等异步路径据此判断能否把中间过程实时写入主内容区。 */
29
+ export function isTuiActive() {
30
+ return state.active;
31
+ }
32
+ export const esc = {
33
+ altOn: '\x1B[?1049h',
34
+ altOff: '\x1B[?1049l',
35
+ // 完整鼠标追踪:1000=按键(按下/释放)+ 1002=拖动 motion + 1006=SGR 编码。
36
+ // 拿到按下/拖动/释放的坐标后,由 layout 在应用层维护选区(mouse.ts 重组报表 → handleMouseEvent):
37
+ // - 左键:内容区按下开选区、拖动扩展(触边自动翻页跨屏)、释放只留高亮,不自动复制;输入框不响应
38
+ // (不干扰正常打字/焦点)。
39
+ // - 右键(单击,press→release 未拖动):落在输入框 → 读剪贴板贴入(setPasteHandler 回调);
40
+ // 落在内容区 → 复制当前选区(若有)到剪贴板(clipboard.ts),静默不弹提示。
41
+ // - 滚轮报表(button&64)转 scrollBy。
42
+ // 代价:终端原生框选被鼠标捕获接管——想用终端原生选区可按住 Shift(多数终端放行)。
43
+ // 1007l=关闭 Alternate Scroll Mode:VSCode xterm.js 进 alt screen(?1049)后默认开 1007,
44
+ // 滚轮发 Up/Down 方向键而非 SGR 鼠标报表;不关的话滚轮会绕过 mouse.swallow 直接触发
45
+ // prompt 的历史导航(recallPrevious/recallNext)→ "输入框打字时滚轮跳到上一条历史消息"。
46
+ mouseOn: '\x1B[?1007l\x1B[?1000h\x1B[?1002h\x1B[?1006h',
47
+ mouseOff: '\x1B[?1006l\x1B[?1002l\x1B[?1000l\x1B[?1007h', // 关:反序;末尾恢复 1007(交还终端默认)
48
+ cursorShow: '\x1B[?25h',
49
+ cursorHide: '\x1B[?25l',
50
+ clearLine: '\x1B[2K',
51
+ home: '\x1B[H',
52
+ };
53
+ /** CUP(光标绝对定位),钳到 >=1。 */
54
+ export function cup(row, col) {
55
+ return `\x1B[${Math.max(1, Math.round(row))};${Math.max(1, Math.round(col))}H`;
56
+ }
57
+ export function getGeo(fh = state.footerH) {
58
+ const rows = stdout.rows || 24;
59
+ const cols = stdout.columns || 80;
60
+ const f = Math.max(1, Math.min(fh, rows - 1));
61
+ return {
62
+ rows,
63
+ cols,
64
+ footerH: f,
65
+ contentTop: 1,
66
+ contentBottom: Math.max(1, rows - f),
67
+ };
68
+ }
69
+ /** (重)设滚动区域 [1, rows-footerH];设完 \x1B[H 归位(conhost 设 DECSTBM 时光标留旧位置行为未定义)。 */
70
+ export function setRegion(fh) {
71
+ const rows = stdout.rows || 24;
72
+ const oldBottom = Math.max(1, rows - state.footerH);
73
+ const g = getGeo(fh);
74
+ state.footerH = g.footerH;
75
+ if (state.active) {
76
+ stdout.write(`\x1B[1;${g.contentBottom}r`); // DECSTBM: top=1, bottom=contentBottom
77
+ // 底栏缩小:原底栏行变回内容区,清掉残留底栏文本(否则提交多行后旧输入残留在底部)
78
+ if (g.contentBottom > oldBottom) {
79
+ let p = '';
80
+ for (let r = oldBottom + 1; r <= g.contentBottom; r++) {
81
+ p += cup(r, 1) + esc.clearLine;
82
+ }
83
+ stdout.write(p);
84
+ }
85
+ stdout.write(esc.home);
86
+ }
87
+ if (state.contentRow > g.contentBottom)
88
+ state.contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
89
+ return g;
90
+ }
91
+ /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+3+planRows(resize 安全)。
92
+ * 非 dim 运行态(与空闲态同色、可任意位置编辑)→ 归当前编辑位,供 IME 锚定气泡到光标处;
93
+ * dim 占位(空 + placeholder)兼容态→ 归输入框起点。供 IME 锚定。 */
94
+ export function runningCaretPos() {
95
+ const g = getGeo();
96
+ const row = g.contentBottom + 3 + state.planRows;
97
+ if (state.lastView && !state.lastView.dim) {
98
+ const promptW = displayWidth(state.lastView.prompt);
99
+ const line = state.lastView.lines[state.lastView.cursorLine] ?? '';
100
+ const before = line.slice(0, state.lastView.cursorCol);
101
+ const col = Math.min(g.cols, promptW + displayWidth(before) + 1);
102
+ return { row, col };
103
+ }
104
+ const text = state.lastView?.dim ? (state.lastView.lines[0] ?? '') : '';
105
+ const contentW = Math.max(0, g.cols - 3); // ❯ =2 + 光标=1
106
+ const w = displayWidth(truncateDisplayHead(text, contentW));
107
+ return { row, col: Math.min(g.cols, 2 + w + 1) };
108
+ }
109
+ export function contentMode() {
110
+ if (!state.active)
111
+ return;
112
+ const g = getGeo();
113
+ if (state.mode === 'running') {
114
+ // 运行态(回尾 / 滚动回看均):真光标归输入框光标位(供 IME 锚定,气泡不跟流式跑)。
115
+ // 滚动态也归输入框——否则上滑看历史时打字,IME 候选气泡会锚到内容区底而非输入框
116
+ // (conhost IME 不跟随 cup 后续移动,须让打字前光标已在输入框)。
117
+ const p = runningCaretPos();
118
+ stdout.write(cup(p.row, p.col));
119
+ }
120
+ else {
121
+ // INPUT 态:回尾归续写位,滚动回看归内容区底(viewport 锁历史;INPUT 态无 IME 锚定需求)
122
+ stdout.write(cup(state.scrollOffset === 0 ? state.contentRow : g.contentBottom, state.scrollOffset === 0 ? state.contentCol : 1));
123
+ }
124
+ }
125
+ /** 运行态用户是否在打字(近期有按键)——是则暂停流式物理写(contentWrite/drawStatusBar/paintLiveAtCursor 跳过物理写),光标留输入框,IME 候选窗稳定不跟流式跑。 */
126
+ export function isStreamingPaused() {
127
+ return state.mode === 'running' && Date.now() < state.userActiveUntil;
128
+ }
@@ -0,0 +1,152 @@
1
+ // L2 滚动与 viewport:回看偏移 / 重画可视区 / 滚轮 / 轮首滚动锁。
2
+ // 从 layout-internal/core.ts 按依赖分层拆出;可变运行态集中在 ./state.ts。
3
+ import { stdout } from 'node:process';
4
+ import { displayWidth, truncateDisplayHead, truncateAnsi, ansiDisplayWidth, padEndAnsiBackground } from '../render.js';
5
+ import { ui } from '../theme.js';
6
+ import * as content from '../content.js';
7
+ import { state } from './state.js';
8
+ import { esc, cup, getGeo, runningCaretPos } from './screen.js';
9
+ import { normalizeSelection, highlightRange } from './selection.js';
10
+ import { repaint } from './input-paint.js';
11
+ // ── 纯常量(可变运行态在 state.ts)──
12
+ const SCROLL_LOCK_MS = 400; // 锁时长:覆盖 OS 缓冲残留 + 常规滚轮惯性;LLM TTFB 多 >200ms,不影响轮中后段滚动
13
+ /** Sticky banner 的 ❯ 前缀(同 repl 的 PROMPT;常量统一视觉)。 */
14
+ const BANNER_PROMPT = '❯ ';
15
+ export const WHEEL_LINES = 3; // 滚轮每格滚动行数
16
+ // ── viewport 滚动回看(Phase 2)──
17
+ /** 是否处于滚动回看态(offset>0,内容区显历史)。prompt 据此在非滚动键时回尾。 */
18
+ export function isScrolled() {
19
+ return state.scrollOffset > 0;
20
+ }
21
+ /** viewport 窗口起点绝对行索引(0-based,对齐 content.sliceFromEnd 的 start)。 */
22
+ export function viewportAbsStart() {
23
+ const g = getGeo();
24
+ const total = content.totalRows();
25
+ const end = Math.max(0, total - state.scrollOffset);
26
+ return Math.max(0, end - g.contentBottom);
27
+ }
28
+ /** 屏行(1-based,内容区内)→ 绝对缓冲行索引(0-based)。 */
29
+ export function screenRowToAbsLine(row) {
30
+ return viewportAbsStart() + (row - 1);
31
+ }
32
+ /**
33
+ * 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
34
+ * offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
35
+ * 有活跃选区(鼠标拖拽中)时,对选中范围套反白——纯视觉,不影响缓冲内容。
36
+ *
37
+ * **滚动回看 sticky banner(输入框上方固定标题的姊妹需求)**:
38
+ * 仅在 scrollOffset > 0 时,在 viewport 第 1 行顶部叠一行横幅,显示「当前 viewport 顶上
39
+ * 那条用户消息」的预览。用户上滑翻历史时,横幅内容随滚动到的 user→agent 对应关系变化;
40
+ * 滑到底(offset===0)自动消失(实时屏可见,无需 banner)。满宽 padding + 底色对比反色
41
+ * 提示「↑ 这是上方滚走的内容」,不与内容区行内 SGR 冲突。
42
+ */
43
+ export function repaintViewport() {
44
+ if (!state.active)
45
+ return;
46
+ const g = getGeo();
47
+ const h = g.contentBottom;
48
+ const slice = content.sliceFromEnd(state.scrollOffset, h);
49
+ const sel = normalizeSelection();
50
+ // viewportAbsStart() 无选区时也用:sticky banner 必须按真实窗口头算,不能降级 0(否则 banner 永不出现)。
51
+ const absStart = viewportAbsStart();
52
+ // sticky banner:仅 scrollOffset>0 时显示;offset=0 即实时屏,user 气泡本来就在视口内,无需 banner。
53
+ const bannerText = state.scrollOffset > 0 ? content.lastUserMessageBefore(absStart) : null;
54
+ const BANNER_ROW = 1; // 横幅占 viewport 第 1 行(会把原第 1 行内容遮住 —— 1 行换"我在看啥"的可读性,可接受)
55
+ let p = '';
56
+ for (let r = 1; r <= h; r++) {
57
+ // 缓冲中的普通行(用户气泡、工具摘要等)仍按写入时宽度保存。窗口缩窄后若原样
58
+ // 输出会触发终端 auto-wrap,打破“一条 buffer 行 = 一条屏幕物理行”。重画时统一
59
+ // 钳到当前列宽:宽度恢复后原内容仍在 buffer 中,可重新完整显示。
60
+ const rawLine = slice[r - 1] ?? '';
61
+ let line = truncateAnsi(rawLine, g.cols);
62
+ // 用户消息是在提交时按当时 cols 填满背景的普通物理行。放宽终端后,buffer 里的
63
+ // 尾部 reset 仍停在旧列宽,若只重画就会留下截图中的灰条短一截。识别 userBg 行并
64
+ // 在 reset 后重新开启同一背景补齐新宽度;不改 buffer,后续缩放仍可从原文重算。
65
+ if (rawLine.includes(ui.userBg)) {
66
+ line = padEndAnsiBackground(line, g.cols, ui.userBg, ui.reset);
67
+ }
68
+ if (bannerText && r === BANNER_ROW) {
69
+ // banner 行:满宽 userBg + ❯ + 单行截断(多行 ⏎ 折叠)+ userBg 补到底 + 底线分隔短横
70
+ const cols = g.cols;
71
+ const oneLine = bannerText.replace(/\s*\n\s*/g, ' ⏎ ');
72
+ const promptW = displayWidth(BANNER_PROMPT);
73
+ const avail = Math.max(1, cols - promptW);
74
+ const truncated = truncateDisplayHead(oneLine, avail);
75
+ const padCount = Math.max(0, cols - promptW - displayWidth(truncated));
76
+ line = `${ui.userBg}${BANNER_PROMPT}${truncated}${' '.repeat(padCount)}${ui.reset}`;
77
+ }
78
+ else if (sel) {
79
+ const absLine = absStart + r - 1;
80
+ if (absLine >= sel.startLine && absLine <= sel.endLine) {
81
+ const lineW = ansiDisplayWidth(line);
82
+ const colStart = absLine === sel.startLine ? sel.startCol : 0;
83
+ const colEnd = absLine === sel.endLine ? sel.endCol : lineW;
84
+ line = highlightRange(line, colStart, colEnd);
85
+ }
86
+ }
87
+ p += cup(r, 1) + esc.clearLine + line;
88
+ }
89
+ // 光标:合并进同一 write(若拆成两次 stdout.write,行写完光标会暂留 contentRow/contentBottom,
90
+ // 流式每 chunk 调一次 → contentBottom 频繁现块状光标白块;打字第一键赶上这瞬 IME 候选气泡锚到 contentBottom)。
91
+ // 运行态(回尾 / 滚动均)归输入框 runningCaretPos(供 IME 锚定);INPUT 态回尾归续写位 / 滚动归内容区底。
92
+ if (state.mode === 'running') {
93
+ const c = runningCaretPos();
94
+ p += cup(c.row, c.col);
95
+ }
96
+ else {
97
+ p += cup(state.scrollOffset === 0 ? state.contentRow : g.contentBottom, state.scrollOffset === 0 ? state.contentCol : 1);
98
+ }
99
+ stdout.write(p);
100
+ }
101
+ /** 滚动 delta 行(正=往新、负=往旧);钳 [0, max(0, total-contentBottom)];变则重画 + 刷底栏(显指示、光标回输入框)。 */
102
+ export function scrollBy(delta) {
103
+ if (!state.active)
104
+ return;
105
+ if (Date.now() < state.scrollLockUntil)
106
+ return; // 轮首滚动锁:吸收发消息前后 stdin 残留滚轮事件,保 agent 输出从底部开始
107
+ const g = getGeo();
108
+ const total = content.totalRows();
109
+ const maxOff = Math.max(0, total - g.contentBottom);
110
+ const off = Math.max(0, Math.min(state.scrollOffset + delta, maxOff));
111
+ if (off === state.scrollOffset)
112
+ return;
113
+ // 进入滚动态前清掉 spinner 内联帧:滚动态由状态行心跳兜底,内联帧若残留会卡在历史视图某行。
114
+ if (state.scrollOffset === 0 && off > 0 && state.frameRow) {
115
+ stdout.write(cup(Math.min(state.frameRow, g.contentBottom), state.frameCol) + esc.clearLine);
116
+ state.frameRow = 0;
117
+ state.frameCol = 0;
118
+ }
119
+ state.scrollOffset = off;
120
+ repaintViewport();
121
+ repaint();
122
+ }
123
+ /**
124
+ * 滚动一格「滚轮量」(WHEEL_LINES 行):dir=+1 看更旧(等同 ↑ / PgUp),dir=-1 看更新(等同 ↓ / PgDn)。
125
+ * 鼠标滚轮与键盘裸 ↑/↓ 共用同一入口,保证两者行为与步长恒一致。
126
+ */
127
+ export function scrollWheel(dir) {
128
+ scrollBy(dir > 0 ? WHEEL_LINES : -WHEEL_LINES);
129
+ }
130
+ /** 回尾(offset=0);仅当原本滚动过才重画(避免每轮 enterRunningMode 闪烁)。 */
131
+ export function resetScroll() {
132
+ if (state.scrollOffset === 0)
133
+ return;
134
+ state.scrollOffset = 0;
135
+ repaintViewport();
136
+ repaint();
137
+ }
138
+ /** 发消息轮首短时锁住滚动:吸收 stdin 残留滚轮事件(发消息前后的滚轮惯性 / OS 缓冲延迟到达),
139
+ * 防 resetScroll 回尾后被 onRunningKey 接到的残留事件重新滚上去——致 agent 输出进缓冲、显示在历史区。
140
+ * 锁只挡 scrollBy(滚轮 / PgUp-PgDn);不影响 contentWrite 写屏(offset=0 时照常物理写,agent 输出从底部开始)。
141
+ * 默认 SCROLL_LOCK_MS 后自动解锁;enterInputMode(轮末)也清锁。用户轮中后段仍可上滑(满足"输出时能看历史")。 */
142
+ export function lockScrollToBottom(ms = SCROLL_LOCK_MS) {
143
+ state.scrollLockUntil = Date.now() + ms;
144
+ }
145
+ /** 解锁(轮末 enterInputMode / 测试用)。 */
146
+ export function unlockScroll() {
147
+ state.scrollLockUntil = 0;
148
+ }
149
+ /** 轮首滚动锁是否生效(测试用)。 */
150
+ export function isScrollLocked() {
151
+ return Date.now() < state.scrollLockUntil;
152
+ }