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
package/dist/ui/layout.js CHANGED
@@ -1,2288 +1,3 @@
1
- import { stdin, stdout } from 'node:process';
2
- import { inspect } from 'node:util';
3
- import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncateAnsi, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
4
- import { ui, resetTerminalBackground } from './theme.js';
5
- import * as content from './content.js';
6
- import * as mouse from './mouse.js';
7
- import { reset as resetBatches, shiftBatchesAfter, setMaxCols } from './batch.js';
8
- import { copyToClipboard, readClipboard } from './clipboard.js';
9
- import { renderMarkdown } from './markdown.js';
10
- import { t } from '../i18n/index.js';
11
- // ── 内部状态 ──
12
- let active = false;
13
- /** 是否处于全屏 TUI(alt screen)激活态。非 TTY / 嵌入宿主(host)下为 false。
14
- * 子 agent 等异步路径据此判断能否把中间过程实时写入主内容区。 */
15
- export function isTuiActive() {
16
- return active;
17
- }
18
- // ── 裸 console 防御:第三方库(如 openai SDK)可能用 console.log 直写 stdout,
19
- // 在 RUNNING 态会落到光标所在的底栏输入框,污染输入。进入 TUI 后把 console.*
20
- // 劫持到 contentWrite,统一进内容区(运行态下 contentWrite 末尾会把真光标归位输入框),
21
- // 既不再泄漏到输入框,也不会破坏 TUI 布局。TUI 外(active=false)不劫持,
22
- // 恢复原始 console,保证 host 子进程 JSON 协议 / 退出日志正常。
23
- let consoleHookInstalled = false;
24
- const origConsole = {
25
- log: console.log,
26
- error: console.error,
27
- warn: console.warn,
28
- info: console.info,
29
- };
30
- function routeConsoleToContent(method) {
31
- const c = console;
32
- c[method] = (...args) => {
33
- if (active && ui.isTTY) {
34
- let s;
35
- try {
36
- s = args
37
- .map((a) => (typeof a === 'string' ? a : inspect(a, { depth: 4 })))
38
- .join(' ');
39
- }
40
- catch {
41
- s = String(args[0]);
42
- }
43
- if (!s.endsWith('\n'))
44
- s += '\n';
45
- contentWrite(s);
46
- }
47
- else {
48
- origConsole[method](...args);
49
- }
50
- };
51
- }
52
- function installConsoleGuard() {
53
- if (consoleHookInstalled)
54
- return;
55
- routeConsoleToContent('log');
56
- routeConsoleToContent('error');
57
- routeConsoleToContent('warn');
58
- routeConsoleToContent('info');
59
- consoleHookInstalled = true;
60
- }
61
- function uninstallConsoleGuard() {
62
- if (!consoleHookInstalled)
63
- return;
64
- console.log = origConsole.log;
65
- console.error = origConsole.error;
66
- console.warn = origConsole.warn;
67
- console.info = origConsole.info;
68
- consoleHookInstalled = false;
69
- }
70
- let mode = 'input';
71
- let footerH = 6; // 1 虚拟空行 + 1 spinner行 + 1 上线 + 输入行数 + 1 下线 + 1 model行(两行式底栏)
72
- let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
73
- let contentCol = 1; // 续写位列(1-based)
74
- // 上一次 paintLiveAtCursor 实际画帧的屏坐标(0=未画)。clearLiveAtCursor 清"这行"而非当前续写位——
75
- // 防 spinner 运行期间续写位漂移时清错行、旧帧行残留(见 557e678 移除 isStreamingPaused 后的间歇性 frame 泄漏)。
76
- let frameRow = 0;
77
- let frameCol = 0;
78
- let segmentStartRow = 1; // 当前 md 段起始屏行(供 contentWriteMd 定位段末续写位;段内行数由 content 段标记跟踪)
79
- let scrollOffset = 0; // 滚动回看距尾行数(0=尾,跟随新内容);>0 时 viewport 显历史、状态行显滚动指示
80
- let scrollLockUntil = 0; // 发消息轮首滚动锁(绝对时间戳 ms,0=未锁):吸收 stdin 残留滚轮事件,防 resetScroll 回尾后被重新滚上去
81
- const SCROLL_LOCK_MS = 400; // 锁时长:覆盖 OS 缓冲残留 + 常规滚轮惯性;LLM TTFB 多 >200ms,不影响轮中后段滚动
82
- let base = null;
83
- // 运行态实时 token 用量(agent core 流式推送,轮末 repl 清 undefined)。
84
- // composeModelLine 在 RUNNING 态把它画成 chip 放 context 进度条左侧;
85
- // 不主动触发重画——RUNNING 态 turnTimer 80ms 心跳重画自然取最新值。
86
- let liveUsage;
87
- let statusText = '';
88
- let spinnerFrame;
89
- let turnStart = null; // RUNNING 态起点(Date.now());INPUT 态为 null。composeStatus 据此拼走时。
90
- let turnTimer = null; // 走时刷新计时器(独立于 spinner):流式期间 spinner 停转,由它续刷状态行。
91
- /** 当前 plan chip 占用脚栏行数(1 或 2)。composePlanLines 每次重算后写入。
92
- * 驱动 paintInput setRegion(fh) 动态撑高脚栏;drawStatusBar 据此画 1/2 行 plan,
93
- * 与 spinner/上线/输入/下线/model 行的 +1/+2/+3/+4/+5 偏移天然一致(contentBottom 自动重算)。 */
94
- let planRows = 1;
95
- let runningFrame = -1; // 运行态状态行 chip 心跳帧(转圈帧 明灭);INPUT 态 -1 退回静态 ●
96
- const RUNNING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
97
- // 运行态用户打字时暂停流式物理写:流式每个 token 要 cup 到 contentRow 写入,IME 候选窗逐光标移动跟踪会跟过去;
98
- // 用户打字期间只喂缓冲、不物理写,光标留输入框;停手 USER_ACTIVE_PAUSE_MS 后 flush 重画缓冲内容。
99
- let userActiveUntil = 0; // 打字活跃截止时刻(Date.now()+PAUSE);0=未活跃
100
- let flushTimer = null; // 用户停手后 flush 缓冲内容(repaintViewport)
101
- const USER_ACTIVE_PAUSE_MS = 1500;
102
- /** Sticky banner 的 ❯ 前缀(同 repl 的 PROMPT;常量统一视觉)。 */
103
- const BANNER_PROMPT = '❯ ';
104
- let lastView = null;
105
- let lastMenuStartRow = 0; // 上次菜单起始屏行(供擦除)
106
- let lastMenuRows = 0;
107
- let resizeTimer = null;
108
- let exitHandler = null;
109
- let sigwinchHandler = null;
110
- // markdown 流式段:agent onText 的 chunk 累积到 mdBuf,每 chunk 把整段经 renderMarkdown
111
- // 渲成自洽行,replace 缓冲段(content.setLines)+ repaintViewport 重画。mdActive 期间任何
112
- // 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
113
- let mdActive = false;
114
- let mdBuf = '';
115
- let selection = null;
116
- let selecting = false; // 左键按下中(press→release 之间)
117
- let mouseEnabled = true; // 导航菜单(picker)期间置 false:只吞报表不做选区/滚动,防菜单被 viewport 重画覆盖
118
- const WHEEL_LINES = 3; // 滚轮每格滚动行数
119
- let inputSelection = null;
120
- /** 上次画过输入框的 lastView 签名(lines 数 + 每行 display_w 累加);变了说明 prompt.ts 改了文本 → 选区锚点失效,清掉。 */
121
- let lastInputSig = '';
122
- // 点击输入框粘贴:当前文本输入方(prompt.ts / repl 运行态 typeahead)注册回调,接收剪贴板文本贴入。
123
- // 只在输入区(底栏输入行)内右键单击(未拖动)时触发——与内容区选区互不干扰(内容区左键=框选,右键=复制)。
124
- let pasteHandler = null;
125
- /** 鼠标点击输入框 → prompt.ts(文本 source of truth)提供的光标应用回调;
126
- * layout 只做"点击 → 算新位置"的映射,实际改 cl/cc 归 prompt(同 pasteHandler 套路)。
127
- * 若未注册(非输入态/未进 prompt):仅做视觉反馈,cl/cc 不变。 */
128
- let cursorChangeHandler = null;
129
- const esc = {
130
- altOn: '\x1B[?1049h',
131
- altOff: '\x1B[?1049l',
132
- // 完整鼠标追踪:1000=按键(按下/释放)+ 1002=拖动 motion + 1006=SGR 编码。
133
- // 拿到按下/拖动/释放的坐标后,由 layout 在应用层维护选区(mouse.ts 重组报表 → handleMouseEvent):
134
- // - 左键:内容区按下开选区、拖动扩展(触边自动翻页跨屏)、释放只留高亮,不自动复制;输入框不响应
135
- // (不干扰正常打字/焦点)。
136
- // - 右键(单击,press→release 未拖动):落在输入框 → 读剪贴板贴入(setPasteHandler 回调);
137
- // 落在内容区 → 复制当前选区(若有)到剪贴板(clipboard.ts),静默不弹提示。
138
- // - 滚轮报表(button&64)转 scrollBy。
139
- // 代价:终端原生框选被鼠标捕获接管——想用终端原生选区可按住 Shift(多数终端放行)。
140
- // 1007l=关闭 Alternate Scroll Mode:VSCode xterm.js 进 alt screen(?1049)后默认开 1007,
141
- // 滚轮发 Up/Down 方向键而非 SGR 鼠标报表;不关的话滚轮会绕过 mouse.swallow 直接触发
142
- // prompt 的历史导航(recallPrevious/recallNext)→ "输入框打字时滚轮跳到上一条历史消息"。
143
- mouseOn: '\x1B[?1007l\x1B[?1000h\x1B[?1002h\x1B[?1006h',
144
- mouseOff: '\x1B[?1006l\x1B[?1002l\x1B[?1000l\x1B[?1007h', // 关:反序;末尾恢复 1007(交还终端默认)
145
- cursorShow: '\x1B[?25h',
146
- cursorHide: '\x1B[?25l',
147
- clearLine: '\x1B[2K',
148
- home: '\x1B[H',
149
- };
150
- /** CUP(光标绝对定位),钳到 >=1。 */
151
- function cup(row, col) {
152
- return `\x1B[${Math.max(1, Math.round(row))};${Math.max(1, Math.round(col))}H`;
153
- }
154
- export function getGeo(fh = footerH) {
155
- const rows = stdout.rows || 24;
156
- const cols = stdout.columns || 80;
157
- const f = Math.max(1, Math.min(fh, rows - 1));
158
- return {
159
- rows,
160
- cols,
161
- footerH: f,
162
- contentTop: 1,
163
- contentBottom: Math.max(1, rows - f),
164
- };
165
- }
166
- /** (重)设滚动区域 [1, rows-footerH];设完 \x1B[H 归位(conhost 设 DECSTBM 时光标留旧位置行为未定义)。 */
167
- export function setRegion(fh) {
168
- const rows = stdout.rows || 24;
169
- const oldBottom = Math.max(1, rows - footerH);
170
- const g = getGeo(fh);
171
- footerH = g.footerH;
172
- if (active) {
173
- stdout.write(`\x1B[1;${g.contentBottom}r`); // DECSTBM: top=1, bottom=contentBottom
174
- // 底栏缩小:原底栏行变回内容区,清掉残留底栏文本(否则提交多行后旧输入残留在底部)
175
- if (g.contentBottom > oldBottom) {
176
- let p = '';
177
- for (let r = oldBottom + 1; r <= g.contentBottom; r++) {
178
- p += cup(r, 1) + esc.clearLine;
179
- }
180
- stdout.write(p);
181
- }
182
- stdout.write(esc.home);
183
- }
184
- if (contentRow > g.contentBottom)
185
- contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
186
- return g;
187
- }
188
- /** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+3+planRows(resize 安全)。
189
- * 非 dim 运行态(与空闲态同色、可任意位置编辑)→ 归当前编辑位,供 IME 锚定气泡到光标处;
190
- * dim 占位(空 + placeholder)兼容态→ 归输入框起点。供 IME 锚定。 */
191
- function runningCaretPos() {
192
- const g = getGeo();
193
- const row = g.contentBottom + 3 + planRows;
194
- if (lastView && !lastView.dim) {
195
- const promptW = displayWidth(lastView.prompt);
196
- const line = lastView.lines[lastView.cursorLine] ?? '';
197
- const before = line.slice(0, lastView.cursorCol);
198
- const col = Math.min(g.cols, promptW + displayWidth(before) + 1);
199
- return { row, col };
200
- }
201
- const text = lastView?.dim ? lastView.lines[0] ?? '' : '';
202
- const contentW = Math.max(0, g.cols - 3); // ❯ =2 + 光标=1
203
- const w = displayWidth(truncateDisplayHead(text, contentW));
204
- return { row, col: Math.min(g.cols, 2 + w + 1) };
205
- }
206
- export function contentMode() {
207
- if (!active)
208
- return;
209
- const g = getGeo();
210
- if (mode === 'running') {
211
- // 运行态(回尾 / 滚动回看均):真光标归输入框光标位(供 IME 锚定,气泡不跟流式跑)。
212
- // 滚动态也归输入框——否则上滑看历史时打字,IME 候选气泡会锚到内容区底而非输入框
213
- // (conhost IME 不跟随 cup 后续移动,须让打字前光标已在输入框)。
214
- const p = runningCaretPos();
215
- stdout.write(cup(p.row, p.col));
216
- }
217
- else {
218
- // INPUT 态:回尾归续写位,滚动回看归内容区底(viewport 锁历史;INPUT 态无 IME 锚定需求)
219
- stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
220
- }
221
- }
222
- /** 运行态用户是否在打字(近期有按键)——是则暂停流式物理写(contentWrite/drawStatusBar/paintLiveAtCursor 跳过物理写),光标留输入框,IME 候选窗稳定不跟流式跑。 */
223
- export function isStreamingPaused() {
224
- return mode === 'running' && Date.now() < userActiveUntil;
225
- }
226
- /**
227
- * 运行态用户打字时调(repl onRunningKey 每次按键):标记活跃 + 重置 flush 定时器。
228
- * 活跃期间流式只喂缓冲不物理写(光标不入内容区);用户停手 USER_ACTIVE_PAUSE_MS 后 flush:
229
- * repaintViewport 重画缓冲内容 + drawStatusBar 刷状态行 + 光标归输入框。
230
- * 解决 IME 候选窗跟流式输出跑:流式写必须 cup 到 contentRow,IME 逐光标跟踪→气泡跟跑;打字时暂停写则光标不动。
231
- */
232
- export function setUserActive() {
233
- if (!active || mode !== 'running')
234
- return;
235
- userActiveUntil = Date.now() + USER_ACTIVE_PAUSE_MS;
236
- if (flushTimer)
237
- clearTimeout(flushTimer);
238
- flushTimer = setTimeout(() => {
239
- flushTimer = null;
240
- userActiveUntil = 0;
241
- if (active && mode === 'running' && scrollOffset === 0) {
242
- repaintViewport(); // 重画内容区(显示活跃期间缓冲的流式内容)
243
- drawStatusBar(); // 刷状态行(活跃期间冻住,现恢复)
244
- const p = runningCaretPos();
245
- stdout.write(cup(p.row, p.col));
246
- }
247
- }, USER_ACTIVE_PAUSE_MS);
248
- flushTimer.unref();
249
- }
250
- /**
251
- * 内容区正文写:CUP 到续写位 + 写出 + 按字符宽度/折行更新续写位 + 同步入 content 缓冲(供滚动回看)。
252
- * 滚动区域内写满自动在区域内滚动,底栏在区域外不被顶。非 TTY / 未激活:直接 stdout.write(内联退化)。
253
- *
254
- * 逐字符分类(按码点取字符,SGR 序列整段识别):SGR 码 → 0 宽跟踪 + content.feedSgr;其他 CSI/OSC → 跳过
255
- * (callers 理论上不传);\n / 折行 / tab 换行 → 行推进 + content.breakRow;可见字符 → charWidth 跟踪 +
256
- * content.feedChar。SGR 在终端 0 宽,故续写位只按可见字符推进(与终端实际光标一致)。
257
- */
258
- export function contentWrite(s) {
259
- if (!active || !ui.isTTY) {
260
- stdout.write(s);
261
- return;
262
- }
263
- if (mdActive)
264
- commitMd(); // 非 md 写接续:先收尾 md 段(清 segMark,否则 setLines 会截到旧段)
265
- // 滚动回看时(scrollOffset>0)只喂缓冲 + 推进续写位,不物理写——否则新流式内容覆盖 viewport 历史行。
266
- // 回尾(scrollOffset===0)才物理写;写起点 = 当前续写位(loop 会推进续写位,故先捕获)。
267
- const g = getGeo();
268
- const cols = g.cols;
269
- const bottom = g.contentBottom;
270
- // resize 后 contentRow 可能过时(拖终端框):
271
- // - 缩小:contentRow > 新 bottom → 钳到新 bottom
272
- // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(committed+1, bottom)
273
- // committed+1 是合法「待写位」(所有行已 breakRow 提交、光标在新空行);用 committed 会把续写位拉回到
274
- // 最后一行 banner/content 上,首次 contentWrite 覆盖 banner(首条消息「插到 logo 下面」bug)。
275
- // 注意:不能用 totalRows()+1——breakRow 后 hasCurrent=true,totalRows 已含当前空行,再 +1 会多跳一行,
276
- // 导致 onDone 摘要行等写 \n 后的 contentWrite 入口多跳 1 行(2 空行 bug)。
277
- if (contentRow > bottom)
278
- contentRow = bottom;
279
- else if (scrollOffset === 0 && contentRow < bottom) {
280
- contentRow = Math.min(content.committedRows() + 1, bottom);
281
- }
282
- const startRow = contentRow;
283
- const startCol = contentCol;
284
- const advanceRow = (r) => (r >= bottom ? bottom : r + 1); // 到底则滚动,续写位留底
285
- // 滚动回看冻结(仿 ink store.pushBlock 的 offset+=newLines):scrollOffset>0 时新内容只入缓冲,
286
- // 但缓冲增长后若 offset 不变,下次 scrollBy→repaintViewport 会取漂移窗口、把新内容画进历史视图
287
- // (「工具消息跳到上面覆盖」bug;滚回底又正常,因 buffer 正确)。故入缓冲前后算 totalRows 差,
288
- // offset += 差以冻住视图(窗口停原绝对行,无需 repaint;滚回底自显尾部新内容)。
289
- const scrolled = scrollOffset > 0;
290
- const totalBefore = scrolled ? content.totalRows() : 0;
291
- let i = 0;
292
- while (i < s.length) {
293
- const rest = s.slice(i);
294
- // SGR 序列 \x1B[...m:0 宽 + 喂缓冲
295
- const sgr = /^\x1b\[[0-9;]*m/.exec(rest);
296
- if (sgr) {
297
- content.feedSgr(sgr[0]);
298
- i += sgr[0].length;
299
- continue;
300
- }
301
- // 其他 CSI / OSC 序列(兜底跳过,不喂缓冲——callers 不该传控制码)
302
- if (s[i] === '\x1b') {
303
- const csi = /^\x1b\[[0-9;]*[A-Za-z]/.exec(rest);
304
- if (csi) {
305
- i += csi[0].length;
306
- continue;
307
- }
308
- const osc = /^\x1b\][^\x07]*(\x07|\x1b\\)/.exec(rest);
309
- if (osc) {
310
- i += osc[0].length;
311
- continue;
312
- }
313
- i += 1;
314
- continue;
315
- }
316
- const cp = s.codePointAt(i) ?? 0;
317
- const ch = String.fromCodePoint(cp);
318
- if (ch === '\n') {
319
- contentRow = advanceRow(contentRow);
320
- content.breakRow();
321
- contentCol = 1;
322
- i += ch.length;
323
- continue;
324
- }
325
- if (ch === '\r') {
326
- contentCol = 1;
327
- i += ch.length;
328
- continue;
329
- }
330
- if (ch === '\t') {
331
- const next = Math.floor((contentCol - 1) / 8) * 8 + 8 + 1;
332
- if (next > cols) {
333
- contentRow = advanceRow(contentRow);
334
- content.breakRow();
335
- contentCol = 1;
336
- }
337
- else
338
- contentCol = next;
339
- i += ch.length;
340
- continue;
341
- }
342
- const cw = charWidth(cp);
343
- if (cw === 0) {
344
- i += ch.length;
345
- continue; // 组合符 / 零宽:不推进
346
- }
347
- if (contentCol + cw - 1 > cols) {
348
- // 当前行放不下:折行
349
- contentRow = advanceRow(contentRow);
350
- content.breakRow();
351
- contentCol = 1;
352
- }
353
- contentCol += cw;
354
- content.feedChar(ch);
355
- i += ch.length;
356
- }
357
- // 跨调用 pending-wrap 提交:循环末若 contentCol>cols(末字符恰好落在最后一列,终端 pending-wrap),
358
- // 必须在此提交换行——否则下一 chunk 的 cup(startRow, cols+1) 被终端 clamp 到 col=cols 且 CUP 清除
359
- // pending,下一可打印字符会覆盖上一行末字符(「新输出插到上文中间」bug);paintLiveAtCursor 也会
360
- // cup 到 cols+1 并 \x1B[2K 擦掉整行。提交后模拟与终端都停在 (下一行,1)、无 pending,等价 ink 的
361
- // 「新输出永远在底」。代价:chunk 恰在末列结束且下一 chunk 以 \n 开头时多一个空行(cosmetic,远好于覆盖)。
362
- // 模拟归一化在物理写 guard 之外(滚动态也要保 buffer 自洽);物理补的 \n 在 guard 之内(滚动态/暂停态不物理写)。
363
- const pendingWrap = contentCol > cols;
364
- if (pendingWrap) {
365
- content.breakRow();
366
- contentRow = advanceRow(contentRow);
367
- contentCol = 1;
368
- }
369
- // 滚动回看冻结:新内容入缓冲后 offset += delta(contentWrite 只增故 ≥0;md 路径可负),钳到 [0, maxOff],
370
- // viewport 窗口停原绝对行(不漂移到新工具消息)。无需 repaint——窗口未变(屏仍显原历史)。
371
- if (scrolled) {
372
- const delta = content.totalRows() - totalBefore;
373
- if (delta !== 0) {
374
- const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
375
- scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
376
- }
377
- }
378
- // 物理写(回尾 offset=0):cup 写起点 + s + (pending 时补 \n 提交换行)+ (运行态 cup 回输入框)。
379
- // 打字中不再暂停物理写——单次 write 结尾 cup 回 runningCaretPos(输入框),IME 锚定不动(跟踪每次 write
380
- // 最终位置);旧设计 isStreamingPaused 暂停写致流式卡顿,现单次写归位已无需暂停。
381
- if (scrollOffset === 0) {
382
- let out = cup(startRow, startCol) + s;
383
- if (pendingWrap)
384
- out += '\n'; // 滚动区域底行触发 DECSTBM 上滚、中段 LF 下移到 (下一行,1)
385
- if (mode === 'running') {
386
- const p = runningCaretPos();
387
- out += cup(p.row, p.col);
388
- }
389
- stdout.write(out);
390
- }
391
- }
392
- /** 进入 markdown 流式段:标记段起点(layout 续写位 + content.beginSegment),清 accumulator。 */
393
- function beginMdSegment() {
394
- segmentStartRow = contentRow;
395
- content.beginSegment();
396
- mdBuf = '';
397
- mdActive = true;
398
- }
399
- /** 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。 */
400
- function commitMd() {
401
- if (!mdActive)
402
- return;
403
- mdActive = false;
404
- mdBuf = '';
405
- content.commitSegment();
406
- }
407
- // ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
408
- // 与 contentWrite 的「续写式追加」不同:banner 自带模式行集合(MoCode logo + 信息行),
409
- // 反复 writeBanner(同一启动会话内同一份 banner 反复画 — 启动后、换 model 后、刷新 snapshot 后)
410
- // 应让 buffer 顶部始终是「最新的一份 banner」,而不是堆 5 份。
411
- // rewriteBanner 在 banner 已建好时原地等长替换顶部 bannerH 行,viewport 自动从 rows[] 头读新版。
412
- //
413
- // bannerH = bannerLines(banner()) 的行数(目前 5:4 行 logo+info + 1 空行分隔)。如未来
414
- // bannerString 变化,调用方需重调 writeBanner 重设 bannerH。
415
- let bannerH = 0;
416
- let bannerRows = []; // 最新写入的 banner 行(供 rewriteBanner 复用 / 调试)
417
- /**
418
- * 在 content 缓冲顶部建一份 banner;**首次**调用建好(bannerH = lines.length),**重复**调用
419
- * 等价 rewriteBanner(lines)(覆盖式刷新,供启动后再写 banner 也安全)。
420
- *
421
- * lines 必须与首次写入等长(否则 content.replaceHead 抛错)— 调用方应统一传 bannerLines(banner())。
422
- * 滚动回看冻结(scrollOffset+=delta / 改 offset 不变):行数不变 offset 不动 → 视图冻结,无须 repaint 风暴。
423
- * 仅当 scrollOffset === 0 时 repaintViewport 让新版 banner 立刻进 viewport 顶部。
424
- */
425
- export function writeBanner(lines) {
426
- if (bannerH === 0) {
427
- // 首次:buffer 通常空(启动最早时),用 setLines 灌入;若 buffer 非空(如 reseed)说明 banner
428
- // 已被前置写过,throw 阻止静默错位 — 调用方应先 reset banner 然后 writeBanner。
429
- if (mdActive)
430
- commitMd();
431
- const existed = content.totalRows();
432
- if (existed !== 0) {
433
- throw new Error(`writeBanner: 首次调用时 buffer 已有 ${existed} 行,需先 clearContent 再 writeBanner(否则会覆盖已有内容)`);
434
- }
435
- content.setLines(lines);
436
- bannerH = lines.length;
437
- bannerRows = lines;
438
- // 续写位推进到 banner 之后:clearContent 把 contentRow 归到 1,writeBanner 灌入 bannerH 行
439
- // 但不动 contentRow → 首次 contentWrite 会从屏行 1 写出(覆盖 banner)。这里同步修正,
440
- // 让首条用户消息从 banner 下方开始(与 buffer 尾部对齐)。仅首次走这条,后续 rewriteBanner 不动续写位。
441
- contentRow = Math.min(bannerH + 1, getGeo().contentBottom);
442
- contentCol = 1;
443
- if (scrollOffset === 0)
444
- repaintViewport();
445
- return;
446
- }
447
- // 后续:等价 rewriteBanner
448
- rewriteBanner(lines);
449
- }
450
- /**
451
- * 把 banner 顶部 bannerH 行**等长替换**为新行;banner 后的内容(对话历史)位置不动。
452
- * 行数与首次 writeBanner 不一致 → 抛错(避免 banner 区错位);如必须改,先清空 content 后再 writeBanner。
453
- * 滚动回看冻结:行数不变,offset 不动。等 scrollOffset 回到 0 时 viewport 自动显新版 banner。
454
- */
455
- export function rewriteBanner(lines) {
456
- if (bannerH === 0) {
457
- // 没建过 banner:等价首次 writeBanner(允许首次调用直接进 rewriteBanner 的别名入口)
458
- writeBanner(lines);
459
- return;
460
- }
461
- if (lines.length !== bannerH) {
462
- throw new Error(`rewriteBanner: 行数 ${lines.length} ≠ 已建 bannerH ${bannerH}(调用方需统一行数)`);
463
- }
464
- content.replaceHead(0, lines);
465
- bannerRows = lines;
466
- if (scrollOffset === 0)
467
- repaintViewport();
468
- }
469
- /** 测试 / 调试用:当前 banner 占多少行;0 表示未启用。 */
470
- export function bannerHeight() {
471
- return bannerH;
472
- }
473
- /**
474
- * markdown 正文写(替代 contentWrite 用于 agent onText):累积 chunk 到 mdBuf,每 chunk 把整段
475
- * mdBuf 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines 截旧 + 写新),
476
- * repaintViewport 重画(仅尾 offset=0 且未暂停)。流式安全:未闭合 fence 照常 emit 进行中代码块;
477
- * renderMarkdown 内部 memo by text 使重复渲染命中缓存。非 TTY / 未激活:直接 stdout.write(与
478
- * contentWrite 一致,管道流式可见)。
479
- *
480
- * 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
481
- * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
482
- * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
483
- */
484
- export function contentWriteMd(s) {
485
- if (!active || !ui.isTTY) {
486
- stdout.write(s);
487
- return;
488
- }
489
- if (!mdActive)
490
- beginMdSegment();
491
- mdBuf += s;
492
- const g = getGeo();
493
- // 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
494
- // 下次 scrollBy→repaintViewport 取漂移窗口、把流式正文画进历史视图。故 setLines 前后算 totalRows 差,
495
- // offset += delta(可负:setLines 重渲染可能缩行)冻住视图。md 段在尾,窗口在上方,冻结后不重叠。
496
- const scrolled = scrollOffset > 0;
497
- const totalBefore = scrolled ? content.totalRows() : 0;
498
- const lines = renderMarkdown(mdBuf, g.cols);
499
- content.setLines(lines);
500
- const segRows = lines.length;
501
- const available = g.contentBottom - segmentStartRow + 1;
502
- contentRow = segRows >= available ? g.contentBottom : segmentStartRow + segRows;
503
- contentCol = 1;
504
- if (scrolled) {
505
- const delta = content.totalRows() - totalBefore;
506
- if (delta !== 0) {
507
- const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
508
- scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
509
- }
510
- }
511
- if (scrollOffset === 0) {
512
- repaintViewport(); // 单次 write 结尾 cup 回 runningCaretPos(运行态),IME 锚输入框;打字中不再暂停
513
- if (mode === 'running') {
514
- const p = runningCaretPos();
515
- stdout.write(cup(p.row, p.col));
516
- }
517
- }
518
- }
519
- /**
520
- * 把一段完整(非流式)assistant 文本一次性渲染成 markdown 写入(供 renderHistory 回显
521
- * /resume / /rollback 复显上下文):渲染一次 → join('\n') → 经 contentWrite 增量写
522
- * (无段 erase / 无 repaintViewport,无闪烁)。与流式 contentWriteMd 的区别:不累积 chunk、
523
- * 不走段替换——一次性把渲染结果当普通带色文本喂入(渲染行各自 ≤ cols、自洽带色,contentWrite
524
- * 的 SGR/折行状态机原样接纳)。非 TTY:退化为 stdout.write 原文(同 contentWrite)。
525
- */
526
- export function contentWriteMdOnce(s) {
527
- if (!active || !ui.isTTY) {
528
- stdout.write(s);
529
- return;
530
- }
531
- const g = getGeo();
532
- const lines = renderMarkdown(s, g.cols);
533
- contentWrite(lines.join('\n'));
534
- }
535
- /** 清空内容区(保留底栏):全屏清 + 重设区域 + 续写位归 (1,1) + 清缓冲 + 回尾。底栏由调用方随后重画。 */
536
- export function clearContent() {
537
- if (!active)
538
- return;
539
- stdout.write('\x1B[1;1H\x1B[2J');
540
- setRegion(footerH);
541
- contentRow = 1;
542
- contentCol = 1;
543
- frameRow = 0;
544
- frameCol = 0;
545
- segmentStartRow = 1;
546
- scrollOffset = 0;
547
- scrollLockUntil = 0;
548
- mdActive = false;
549
- mdBuf = '';
550
- // 清内容缓冲时必须同步重置 banner 状态:否则后续 writeBanner() 会走 rewriteBanner 路径,
551
- // 在已空的 content 上调 replaceHead(0, lines) → startIdx(0) >= committed(0) → 抛错 → REPL 退出。
552
- // /theme、/clear、/resume 等命令 clearContent 后紧接 writeBanner 的场景均依赖此重置。
553
- bannerH = 0;
554
- bannerRows = [];
555
- // 欢迎引导块随 buffer 一起清掉(/clear / /resume 等路径),状态复位后可由 repl 重新写入。
556
- welcomeStart = -1;
557
- welcomeRows = 0;
558
- // 清内容区必须同时作废旧菜单擦除坐标:picker(/resume /rollback /theme)把菜单画在内容区底部,
559
- // 菜单行号缓存在 lastMenuStartRow/lastMenuRows;若不清零,后续 paintInput 会按旧坐标“擦菜单”,
560
- // 把刚 renderHistory/contentWrite 写好的内容(如“已续接会话”提示)清掉,导致用户要滚动一下才刷新。
561
- lastMenuStartRow = 0;
562
- lastMenuRows = 0;
563
- content.reset();
564
- notifyContentReset(); // batch 渲染器同步重置(batch 摘要行索引全部失效)
565
- stdout.write(esc.home);
566
- }
567
- /**
568
- * 撤销(rewind)内容区末尾 n 物理行 —— 供 "刚 echoInput 写下用户气泡、用户撤回"
569
- * 把气泡从可视区与 buffer 一起拿掉。content.rewind 弹出 buffer 末 n 行;layout 这边
570
- * 同步上溯 contentRow(contentTop 钳位,防下溢)、scrollOffset 钳到新 totalRows、
571
- * 最后 repaintViewport 重画可视区(running 态回 runningCaretPos,input 态回续写位)。
572
- *
573
- * 与 clearContent 不同:只擦最近一段、保留更早内容与 buffer;光标归内容区底,
574
- * 不是 (1,1)。常发生在 RUNNING 态的 pending 窗口内 recall —— recall 后调用方
575
- * 通常再 enterInputMode 切回 INPUT 视觉,光标由后续 paintInput 重画。
576
- */
577
- export function rewindContent(rowsToRewind) {
578
- if (!active || rowsToRewind <= 0)
579
- return;
580
- content.rewind(rowsToRewind);
581
- const g = getGeo();
582
- contentRow = Math.max(g.contentTop, contentRow - rowsToRewind);
583
- contentCol = 1;
584
- // 钳 scrollOffset:不能让 viewport 视点超出新 totalRows
585
- const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
586
- scrollOffset = Math.min(scrollOffset, maxOff);
587
- // 末段 frameRow/frameCol 是 spinner 的画位,撤回若跨过 frame 行也不必清——
588
- // repaintViewport 会按新 buffer 重画整片,旧 frame 自然被覆盖。
589
- repaintViewport();
590
- }
591
- // ── 欢迎引导块(新会话开场)──
592
- // 开场写在内容区(banner 之下),教用户怎么开始;首次提交任何输入(消息或斜杠命令)前
593
- // 由 dismissWelcomeBlock 整块从 buffer 撤掉——「一打开就能看见,开始干活就消失」。
594
- let welcomeStart = -1; // 块起点(content.committedRows 口径的绝对行索引)
595
- let welcomeRows = 0; // 块行数(0 = 屏上无欢迎块)
596
- /** 写欢迎引导块(每行自洽带色、行宽须 ≤ cols,contentWrite 状态机兜底折行);已在屏上则跳过。 */
597
- export function writeWelcomeBlock(lines) {
598
- if (!active || !ui.isTTY || lines.length === 0)
599
- return;
600
- if (welcomeRows > 0)
601
- return; // 已在屏上,不重复写
602
- welcomeStart = content.committedRows();
603
- contentWrite(lines.join('\n') + '\n');
604
- welcomeRows = content.committedRows() - welcomeStart;
605
- }
606
- /** 撤掉欢迎引导块:删 buffer 区间(若已被外部清空/裁掉则只复位状态)+ 续写位前移 + 钳位重画。 */
607
- export function dismissWelcomeBlock() {
608
- if (welcomeRows <= 0)
609
- return;
610
- const start = welcomeStart;
611
- const n = welcomeRows;
612
- welcomeRows = 0;
613
- welcomeStart = -1;
614
- if (start < 0 || start >= content.committedRows())
615
- return; // 块已被 clear/trim,无需删
616
- content.deleteFrom(start, n);
617
- const g = getGeo();
618
- contentRow = Math.max(g.contentTop, contentRow - n);
619
- contentCol = 1;
620
- const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
621
- scrollOffset = Math.min(scrollOffset, maxOff);
622
- repaintViewport();
623
- }
624
- /**
625
- * 在绝对行索引 after 后插入自洽行(详情展开用)。
626
- * 滚动回看冻结:scrollOffset>0 时 offset += delta 把窗口冻在原绝对行,
627
- * 与 contentWrite/contentWriteMd 同模式。插入后 repaintViewport 重画 viewport。
628
- *
629
- * 续写位(contentRow):若原写头在插入点之后,前移 lines.length,保持相对位置;
630
- * 若原写头 ≤ after,不变(新行在写头之后)。非 TTY 直接调 content.insertAfter。
631
- *
632
- * keepViewport:鼠标点击展开时 true(视口锚定原位,详情在下方展开,屏幕不跳);
633
- * 子 agent 实时嵌套渲染时传 false —— 那是"新内容"而非"回看展开",必须跟随屏底,
634
- * 否则每插一行就把视口冻住 1 行,子 agent 跑起来后主内容区看着像卡住不动。
635
- */
636
- export function contentInsertAfter(after, lines, keepViewport = true) {
637
- if (!active || lines.length === 0)
638
- return;
639
- const g = getGeo();
640
- const totalBefore = content.totalRows();
641
- const scrolled = scrollOffset > 0;
642
- content.insertAfter(after, lines);
643
- const delta = content.totalRows() - totalBefore;
644
- if (delta === 0)
645
- return;
646
- // 流式 md 段活跃期间:插入点在段起点之前 → segmentStartRow 平移,
647
- // 否则 contentWriteMd 的续写位算错,展开行被下一个 setLines 砍掉。
648
- if (mdActive && after + 1 < segmentStartRow) {
649
- segmentStartRow += delta;
650
- }
651
- // 续写位:原写头绝对行 = viewport 起点 + (contentRow-1) [offset=0];偏移后才一致
652
- if (scrollOffset === 0 && contentRow > after + 1) {
653
- contentRow = Math.min(contentRow + delta, g.contentBottom);
654
- }
655
- // 滚动回看冻结(同 contentWrite)
656
- if (scrolled) {
657
- scrollOffset = Math.max(0, Math.min(scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
658
- }
659
- // 展开工具信息时保持视口位置:当 scrollOffset===0(未滚动)且插入点在视口内时,
660
- // 计算 scrollOffset 让视口锚定在插入前的绝对行位置,展开内容在下面自然展开,
661
- // 而不是自动跳到展开内容底部(用户体验:点击摘要行,视口不动,详情在下方展开)。
662
- // 关键:插入点绝对行 = after;插入前视口尾行绝对行 = totalBefore - 1;
663
- // 插入后要让原视口尾行仍在屏底 → scrollOffset = 插入后新增的、在原视口尾行之后的行数。
664
- if (keepViewport && !scrolled && after < totalBefore) {
665
- // 插入点在原缓冲内(非追加到末尾),计算需要滚动的偏移量
666
- const insertedAfterViewport = after >= (totalBefore - g.contentBottom);
667
- if (insertedAfterViewport) {
668
- // 插入点在视口内:保持视口位置,scrollOffset = 插入行数(展开内容在视口下方)
669
- scrollOffset = Math.min(delta, Math.max(0, content.totalRows() - g.contentBottom));
670
- }
671
- }
672
- // 必须同步平移 batch 索引。若延迟到 dynamic import.then,下一条 mutation 可能已经
673
- // 按插入后的 buffer 创建;旧回调会再平移它一次,导致详情插到空行之后。
674
- shiftBatchesAfter(after, delta);
675
- repaintViewport();
676
- }
677
- /**
678
- * 从绝对行索引 startIdx 起删 n 行(折叠回退用)。
679
- * 滚动回看冻结同 insertAfter:offset -= delta,钳 ≥ 0。续写位若在删区后前移 delta。
680
- */
681
- export function contentDeleteFrom(startIdx, n) {
682
- if (!active || n <= 0)
683
- return;
684
- const g = getGeo();
685
- const totalBefore = content.totalRows();
686
- const scrolled = scrollOffset > 0;
687
- content.deleteFrom(startIdx, n);
688
- const delta = totalBefore - content.totalRows();
689
- if (delta === 0)
690
- return;
691
- // 流式 md 段活跃期间:删除区间全部在段起点之前 → segmentStartRow 回退,
692
- // 否则 contentWriteMd 的续写位算错,段内容错位。
693
- if (mdActive && startIdx + delta <= segmentStartRow) {
694
- segmentStartRow = Math.max(1, segmentStartRow - delta);
695
- }
696
- if (scrollOffset === 0 && contentRow > startIdx + 1) {
697
- contentRow = Math.max(1, contentRow - delta);
698
- }
699
- if (scrolled) {
700
- scrollOffset = Math.max(0, scrollOffset - delta);
701
- }
702
- // batch 摘要行索引平移(用 -delta 表示后段索引前移),同插入路径必须同步。
703
- shiftBatchesAfter(startIdx, -delta);
704
- repaintViewport();
705
- }
706
- /** 当前内容物理行总数(含当前未提交行)。供 batch 渲染器在 endBatch 时定位摘要行索引。 */
707
- export function totalRows() {
708
- return content.totalRows();
709
- }
710
- /** 缓冲尾部(已提交行)是否已经是空白行(去掉 ANSI 后无可见字符)。
711
- * 供 compact 等在 step 循环顶部写通知行前判断是否需要补空行分隔。 */
712
- export function isLastContentRowBlank() {
713
- const committed = content.committedRows();
714
- if (committed === 0)
715
- return false;
716
- const line = content.lineAt(committed - 1);
717
- if (line === null)
718
- return false;
719
- return line.replace(/\x1b\[[0-9;]*m/g, '').trim().length === 0;
720
- }
721
- /** 正文→mutation 首摘要前,把尾部间距强制归一为一条视觉空行。 */
722
- export function normalizeMutationBoundary() {
723
- if (!active || !ui.isTTY)
724
- return;
725
- if (mdActive)
726
- commitMd();
727
- const totalBefore = content.totalRows();
728
- content.normalizeTrailingBlankRows(1);
729
- const g = getGeo();
730
- const delta = content.totalRows() - totalBefore;
731
- if (scrollOffset > 0 && delta !== 0) {
732
- scrollOffset = Math.max(0, Math.min(scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
733
- }
734
- contentRow = Math.min(content.committedRows() + 1, g.contentBottom);
735
- contentCol = 1;
736
- if (scrollOffset === 0)
737
- repaintViewport();
738
- }
739
- /** 命令/Agent 输出→下一条输入气泡前,统一保留恰好一条视觉空行。 */
740
- export function normalizeInputBoundary() {
741
- normalizeMutationBoundary();
742
- }
743
- /** 原地刷新一条内容行(行数不变),用于运行中的工具 batch 更新计数。 */
744
- export function contentReplaceLine(absIdx, line) {
745
- if (!active)
746
- return;
747
- // 原地更新的 batch 摘要可能随追加工具而变长。缓冲区仍把它当作一行,但若直接
748
- // 交给终端超过 cols,终端会自动折行,造成视觉上多出空行且续写位与缓冲失步。
749
- // 在写回缓冲前按 ANSI 可见宽度截断,确保“一条逻辑行 = 一条物理行”。
750
- content.replaceLine(absIdx, truncateAnsi(line, getGeo().cols));
751
- repaintViewport();
752
- }
753
- /** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
754
- export function notifyContentReset() {
755
- // 必须同步清理:clearContent() 后调用方会立即 renderHistory() 重建 batch。
756
- // 若异步 reset,旧清理会在回放完成后反过来抹掉新摘要的点击映射。
757
- resetBatches();
758
- }
759
- // ── viewport 滚动回看(Phase 2)──
760
- /** 是否处于滚动回看态(offset>0,内容区显历史)。prompt 据此在非滚动键时回尾。 */
761
- export function isScrolled() {
762
- return scrollOffset > 0;
763
- }
764
- /** viewport 窗口起点绝对行索引(0-based,对齐 content.sliceFromEnd 的 start)。 */
765
- function viewportAbsStart() {
766
- const g = getGeo();
767
- const total = content.totalRows();
768
- const end = Math.max(0, total - scrollOffset);
769
- return Math.max(0, end - g.contentBottom);
770
- }
771
- /** 屏行(1-based,内容区内)→ 绝对缓冲行索引(0-based)。 */
772
- function screenRowToAbsLine(row) {
773
- return viewportAbsStart() + (row - 1);
774
- }
775
- /** 选区归一化(anchor/end 按阅读顺序排序为 start/end)。无选区返 null。 */
776
- function normalizeSelection() {
777
- if (!selection)
778
- return null;
779
- const a = { line: selection.anchorLine, col: selection.anchorCol };
780
- const b = { line: selection.endLine, col: selection.endCol };
781
- const aFirst = a.line < b.line || (a.line === b.line && a.col <= b.col);
782
- return aFirst
783
- ? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
784
- : { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
785
- }
786
- /** 给自洽带色行的显示列区间 [colStart,colEnd) 套「统一亮黄底 + 黑字」。
787
- * 强制重设前/背景,无视原 SGR:md 字符常带 ui.dim / ui.cyan / ui.gray 等,
788
- * 仅靠 SGR 7 反转对比度极弱;改用显式「亮黄底 SGR 103 + 黑前景 SGR 30」,
789
- * 跨终端一致。退反白用 0 清 SGR,行末 active SGR 自然续接。
790
- * 关键:反白 active 期间**吃掉所有行内 SGR**(不让前景色干扰)——否则
791
- * 行内首个 \x1B[2m(dim)/\x1B[36m(cyan)/\x1B[1m(bold) 进选区后仍生效,
792
- * 前景被压回原色,整片亮黄底被切割、看着「花」;直穿则带 dim 等,对比不足。
793
- * 反白外 SGR 直穿,保留原色。 */
794
- const SEL_OPEN = '\x1B[30;103m'; // 30:黑前景 | 103:亮黄背景
795
- const SEL_OFF = '\x1B[0m'; // 全清 SGR,行末 active 状态续接
796
- function highlightRange(line, colStart, colEnd) {
797
- if (colEnd <= colStart)
798
- return line;
799
- const parts = line.split(/(\x1b\[[0-9;]*m)/);
800
- let w = 0;
801
- let out = '';
802
- let opened = false;
803
- for (let i = 0; i < parts.length; i++) {
804
- if (i % 2 === 1) {
805
- // SGR 段:反白内直接吃,不让行内前景色进选区;反白外直穿,保留原色。
806
- if (opened)
807
- continue;
808
- out += parts[i];
809
- continue;
810
- }
811
- for (const ch of parts[i]) {
812
- const cw = charWidth(ch.codePointAt(0) ?? 0);
813
- if (!opened && w >= colStart && w < colEnd) {
814
- out += SEL_OPEN;
815
- opened = true;
816
- }
817
- if (opened && w >= colEnd) {
818
- out += SEL_OFF;
819
- opened = false;
820
- }
821
- out += ch;
822
- w += cw;
823
- }
824
- }
825
- if (opened)
826
- out += SEL_OFF;
827
- return out;
828
- }
829
- /**
830
- * 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
831
- * offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
832
- * 有活跃选区(鼠标拖拽中)时,对选中范围套反白——纯视觉,不影响缓冲内容。
833
- *
834
- * **滚动回看 sticky banner(输入框上方固定标题的姊妹需求)**:
835
- * 仅在 scrollOffset > 0 时,在 viewport 第 1 行顶部叠一行横幅,显示「当前 viewport 顶上
836
- * 那条用户消息」的预览。用户上滑翻历史时,横幅内容随滚动到的 user→agent 对应关系变化;
837
- * 滑到底(offset===0)自动消失(实时屏可见,无需 banner)。满宽 padding + 底色对比反色
838
- * 提示「↑ 这是上方滚走的内容」,不与内容区行内 SGR 冲突。
839
- */
840
- export function repaintViewport() {
841
- if (!active)
842
- return;
843
- const g = getGeo();
844
- const h = g.contentBottom;
845
- const slice = content.sliceFromEnd(scrollOffset, h);
846
- const sel = normalizeSelection();
847
- // viewportAbsStart() 无选区时也用:sticky banner 必须按真实窗口头算,不能降级 0(否则 banner 永不出现)。
848
- const absStart = viewportAbsStart();
849
- // sticky banner:仅 scrollOffset>0 时显示;offset=0 即实时屏,user 气泡本来就在视口内,无需 banner。
850
- const bannerText = scrollOffset > 0
851
- ? content.lastUserMessageBefore(absStart)
852
- : null;
853
- const BANNER_ROW = 1; // 横幅占 viewport 第 1 行(会把原第 1 行内容遮住 —— 1 行换"我在看啥"的可读性,可接受)
854
- let p = '';
855
- for (let r = 1; r <= h; r++) {
856
- let line = slice[r - 1] ?? '';
857
- if (bannerText && r === BANNER_ROW) {
858
- // banner 行:满宽 userBg + ❯ + 单行截断(多行 ⏎ 折叠)+ userBg 补到底 + 底线分隔短横
859
- const cols = g.cols;
860
- const oneLine = bannerText.replace(/\s*\n\s*/g, ' ⏎ ');
861
- const promptW = displayWidth(BANNER_PROMPT);
862
- const avail = Math.max(1, cols - promptW);
863
- const truncated = truncateDisplayHead(oneLine, avail);
864
- const padCount = Math.max(0, cols - promptW - displayWidth(truncated));
865
- line = `${ui.userBg}${BANNER_PROMPT}${truncated}${' '.repeat(padCount)}${ui.reset}`;
866
- }
867
- else if (sel) {
868
- const absLine = absStart + r - 1;
869
- if (absLine >= sel.startLine && absLine <= sel.endLine) {
870
- const lineW = ansiDisplayWidth(line);
871
- const colStart = absLine === sel.startLine ? sel.startCol : 0;
872
- const colEnd = absLine === sel.endLine ? sel.endCol : lineW;
873
- line = highlightRange(line, colStart, colEnd);
874
- }
875
- }
876
- p += cup(r, 1) + esc.clearLine + line;
877
- }
878
- // 光标:合并进同一 write(若拆成两次 stdout.write,行写完光标会暂留 contentRow/contentBottom,
879
- // 流式每 chunk 调一次 → contentBottom 频繁现块状光标白块;打字第一键赶上这瞬 IME 候选气泡锚到 contentBottom)。
880
- // 运行态(回尾 / 滚动均)归输入框 runningCaretPos(供 IME 锚定);INPUT 态回尾归续写位 / 滚动归内容区底。
881
- if (mode === 'running') {
882
- const c = runningCaretPos();
883
- p += cup(c.row, c.col);
884
- }
885
- else {
886
- p += cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1);
887
- }
888
- stdout.write(p);
889
- }
890
- /** 滚动 delta 行(正=往新、负=往旧);钳 [0, max(0, total-contentBottom)];变则重画 + 刷底栏(显指示、光标回输入框)。 */
891
- export function scrollBy(delta) {
892
- if (!active)
893
- return;
894
- if (Date.now() < scrollLockUntil)
895
- return; // 轮首滚动锁:吸收发消息前后 stdin 残留滚轮事件,保 agent 输出从底部开始
896
- const g = getGeo();
897
- const total = content.totalRows();
898
- const maxOff = Math.max(0, total - g.contentBottom);
899
- const off = Math.max(0, Math.min(scrollOffset + delta, maxOff));
900
- if (off === scrollOffset)
901
- return;
902
- // 进入滚动态前清掉 spinner 内联帧:滚动态由状态行心跳兜底,内联帧若残留会卡在历史视图某行。
903
- if (scrollOffset === 0 && off > 0 && frameRow) {
904
- stdout.write(cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine);
905
- frameRow = 0;
906
- frameCol = 0;
907
- }
908
- scrollOffset = off;
909
- repaintViewport();
910
- repaint();
911
- }
912
- /**
913
- * 滚动一格「滚轮量」(WHEEL_LINES 行):dir=+1 看更旧(等同 ↑ / PgUp),dir=-1 看更新(等同 ↓ / PgDn)。
914
- * 鼠标滚轮与键盘裸 ↑/↓ 共用同一入口,保证两者行为与步长恒一致。
915
- */
916
- export function scrollWheel(dir) {
917
- scrollBy(dir > 0 ? WHEEL_LINES : -WHEEL_LINES);
918
- }
919
- /** 清活跃选区(不复制),若原有选区则重画去掉反白。供 ESC / 点击别处 / 退出滚动态等场景调。 */
920
- export function clearSelection() {
921
- if (!selection && !inputSelection)
922
- return;
923
- selection = null;
924
- if (inputSelection) {
925
- inputSelection = null;
926
- if (active && base && lastView)
927
- paintInput(lastView);
928
- return;
929
- }
930
- if (scrollOffset >= 0)
931
- repaintViewport();
932
- }
933
- /**
934
- * 注册"点击输入框粘贴"回调:当前持有文本输入的模块(prompt.ts 的 promptWithSlashMenu / repl 运行态
935
- * typeahead)在挂键盘监听时调,退出时传 null 注销。点击输入行(未拖动)时 layout 读剪贴板后回调此函数,
936
- * 由回调方完成插入(各输入状态的行/光标结构不同,layout 不掺和文本编辑逻辑,只管"贴入"这个动作触发)。
937
- */
938
- export function setPasteHandler(fn) {
939
- pasteHandler = fn;
940
- }
941
- /** 注册"点击输入框 → 改光标"回调:prompt.ts 等文本所有者挂此回调后,
942
- * 鼠标左键落输入行算出的新 (line, col) 会在 prompt 下次 redraw 前喂入此回调
943
- * (prompt 用它写自己的 cl / cc,真正改 source of truth)。
944
- * 传 null 注销(在 cleanup / 退出输入态时调,防下个 prompt 实例被旧回调污染)。 */
945
- export function setCursorChangeHandler(fn) {
946
- cursorChangeHandler = fn;
947
- }
948
- /** overlay 鼠标接管者(composer 输入面板等全屏弹窗)。非 null 时所有鼠标事件先喂给它,
949
- * 返回 true = 已消费(不再走 layout 默认处理);弹窗自己消费全部事件,
950
- * 防止背景选区/翻页触发 repaintViewport 重画覆盖弹窗。传 null 注销。 */
951
- let overlayMouseHandler = null;
952
- export function setOverlayMouseHandler(fn) {
953
- overlayMouseHandler = fn;
954
- }
955
- /** picker / 介入面板期间禁用鼠标选区与拖拽(避免 viewport 重画覆盖菜单);滚轮仍可用。面板退出后恢复。 */
956
- export function setMouseEnabled(v) {
957
- mouseEnabled = v;
958
- if (!v) {
959
- selecting = false;
960
- if (selection) {
961
- selection = null;
962
- repaintViewport();
963
- }
964
- if (inputSelection) {
965
- inputSelection = null;
966
- if (active && base && lastView)
967
- paintInput(lastView);
968
- }
969
- }
970
- }
971
- /** 从归一化选区抠出纯文本(去 ANSI,按显示列裁切,行间 \n 拼接)。越界行跳过(缓冲被 trim 等边界情况)。 */
972
- function extractSelectionText(sel) {
973
- const out = [];
974
- for (let abs = sel.startLine; abs <= sel.endLine; abs++) {
975
- const raw = content.lineAt(abs);
976
- if (raw == null)
977
- continue;
978
- const plain = stripAnsi(raw);
979
- const lineW = displayWidth(plain);
980
- const colStart = abs === sel.startLine ? sel.startCol : 0;
981
- const colEnd = abs === sel.endLine ? sel.endCol : lineW;
982
- out.push(sliceByDisplayCol(plain, colStart, colEnd));
983
- }
984
- return out.join('\n');
985
- }
986
- /** 输入框选区归一化:按 (line, col) 字典序排成 start/end。无选区返 null。 */
987
- function normalizeInputSelection() {
988
- if (!inputSelection)
989
- return null;
990
- const a = inputSelection.anchor;
991
- const b = inputSelection.end;
992
- const aFirst = a.line < b.line || (a.line === b.line && a.col <= b.col);
993
- return aFirst
994
- ? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
995
- : { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
996
- }
997
- /** 从归一化输入框选区抠出纯文本(lines 上不带 ANSI,直接按 display_col 切)。越界行跳到该行末尾。 */
998
- function extractInputSelectionText(sel) {
999
- if (!lastView)
1000
- return '';
1001
- const out = [];
1002
- for (let l = sel.startLine; l <= sel.endLine; l++) {
1003
- const raw = lastView.lines[l];
1004
- if (raw == null)
1005
- continue;
1006
- const lineW = displayWidth(raw);
1007
- const colStart = l === sel.startLine ? sel.startCol : 0;
1008
- const colEnd = l === sel.endLine ? sel.endCol : lineW;
1009
- out.push(sliceByDisplayCol(raw, colStart, colEnd));
1010
- }
1011
- return out.join('\n');
1012
- }
1013
- /** 输入框 lastView 签名:lines 数 + 每行 display_w 累加;任何键改动 lines 即变化,用作 inputSelection 失效判据。 */
1014
- function inputViewSig(view) {
1015
- if (!view)
1016
- return '';
1017
- return view.lines.length + '|' + view.lines.map((l) => displayWidth(l).toString()).join(',');
1018
- }
1019
- /** 清输入框反白选区并重画(若 active)。不复制,仅清。供 clearSelection / setMouseEnabled 复用。 */
1020
- function clearInputSelection() {
1021
- if (!inputSelection)
1022
- return;
1023
- inputSelection = null;
1024
- if (active && base && lastView)
1025
- paintInput(lastView);
1026
- }
1027
- /** 屏行是否落在底栏输入行范围内(paintInput 的 firstInputRow..firstInputRow+inputRowsAvail-1)。 */
1028
- function isInputRow(row) {
1029
- const g = getGeo();
1030
- const firstInputRow = g.contentBottom + 3 + planRows;
1031
- const inputRowsAvail = Math.max(0, g.footerH - 4 - planRows);
1032
- return row >= firstInputRow && row < firstInputRow + inputRowsAvail;
1033
- }
1034
- /** 右键单击输入行(未拖动的 press→release):读剪贴板 + 回调 pasteHandler 贴入。异步但不阻塞其他事件。 */
1035
- function pasteIntoInput() {
1036
- if (!pasteHandler)
1037
- return;
1038
- const handler = pasteHandler;
1039
- readClipboard()
1040
- .then((text) => {
1041
- if (text && active)
1042
- handler(text);
1043
- })
1044
- .catch(() => { });
1045
- }
1046
- /** 鼠标左键落输入行:进输入态(若是 running)→ 用统一的 display_col 单位算出"目标光标位"
1047
- * → paintInput 用新光标重画(末步 cup 把终端真光标移到点击位,默认即竖线/闪烁块),
1048
- * 并同步调 cursorChangeHandler 让 prompt.ts 改自己的 cl/cc(source of truth),下次按键按新位写字。
1049
- *
1050
- * 单位说明:
1051
- * - paintInput 的 view.cursorCol 单位 = display_col(同 paintInput 的 dispCursorCol,见 prompt.ts:186)
1052
- * - prompt 的 cc 单位 = char_idx(字符索引)
1053
- * 本函数同时给出 targetLine、targetDisplayCol(paintInput 用)、targetCharCol(prompt 用),
1054
- * 故 visitColToCharCol 那一套反推只是用于把 click 屏幕列换成字符偏移,与 paintInput 的视协议完全对齐。
1055
- *
1056
- * 点击行为:
1057
- * - 屏幕 visRow/visCol 用 charWidth 计列(中文/Emoji 全角占 2);落在宽字符中段归到字符左边界。
1058
- * - 累加 flat[0..flatIdx-1] 的 display_w + 字符长度 → targetDisplayCol/targetCharCol,与 paintInput 渲染一致。
1059
- * - 段接缝点击因 paintInput 协议限制(<= 还是 <),会落到前段末;这是 paintInput 全局行为,不归本函数。
1060
- * - 未注册 handler(非输入态)→ 仅视觉真光标可见;cl/cc 不变。 */
1061
- function setInputCursorFromClick(screenRow, screenCol) {
1062
- if (!active || !base || !lastView)
1063
- return;
1064
- // running 态:先收归输入态(enterInputMode 会 paintInput 整帧重画;之后我们再按新光标重画一次覆盖即可)
1065
- if (mode !== 'input') {
1066
- enterInputMode(statusText);
1067
- }
1068
- const pos = inputScreenToInputPos(screenRow, screenCol);
1069
- if (!pos) {
1070
- paintInput(lastView);
1071
- return;
1072
- }
1073
- // 三步原子:
1074
- // 1) lastView.cursorLine = targetLine — view.cursorLine 单位 = lines 行号 in dispLines(供 paintInput)
1075
- // 2) lastView.cursorCol = targetDisplayCol — view.cursorCol 单位 = display_col(供 paintInput,
1076
- // paintInput 内部 wrap/flat 后用 cursorCol 算 visLine + cursorVisCol)
1077
- // 3) paintInput 重画——末步 cup 把真光标移到点击位的可视位置
1078
- // 4) cursorChangeHandler(dispatchFlatIdx, dispatchInSegVis) — 给 prompt 的"屏 tap 原坐标",
1079
- // prompt 自己重做 flat 算 cl/cc(扣 chip prefix,与 layout 端的 stamp 时 dispLines 一致即可)。
1080
- // 未注册 handler(非输入态)→ 仅步骤 3 可见真光标移动;步骤 4 不动 prompt 的 cl/cc。
1081
- lastView = { ...lastView, cursorLine: pos.line, cursorCol: pos.displayCol };
1082
- paintInput(lastView);
1083
- if (cursorChangeHandler)
1084
- cursorChangeHandler(pos.flatIdx, pos.inSegVis);
1085
- }
1086
- /**
1087
- * 把屏(行,列)反推到 (lines 行号, 行内 display_col, flatIdx, 段内显示列),
1088
- * 算法与 setInputCursorFromClick 抽出前等价——基于 lastView 的"折行 + 滚动窗"几何,
1089
- * 与 paintInput 的 windowInputVis 同源(startVis 同锚),保证 press/drag 落点 = 画上高亮位置。
1090
- * 越界点(空行)→ 落末行末段末。
1091
- */
1092
- function inputScreenToInputPos(screenRow, screenCol) {
1093
- if (!lastView)
1094
- return null;
1095
- const g = getGeo();
1096
- const promptW = displayWidth(lastView.prompt);
1097
- const firstInputRow = g.contentBottom + 3 + planRows;
1098
- const inputRowsAvail = Math.max(0, g.footerH - 4 - planRows);
1099
- // 输入框可视区的 (visRow, visCol) 屏幕坐标 → 0-based。
1100
- // 点到可视区末行之下(空白区)→ 落到最末可视行(光标归最后一段);isInputRow 已挡可视区之上的点击。
1101
- const visRow = Math.max(0, Math.min(screenRow - firstInputRow, Math.max(0, inputRowsAvail - 1)));
1102
- // 屏幕列 (1-based SGR) → 显示列 (0-based),再扣 prompt 宽;尾点容许越界 clip 到 >=0。
1103
- const visCol = Math.max(0, screenCol - 1 - promptW);
1104
- // 复刻 windowInputVis 的折行 + 滚动窗(输入态, lines 全量参与)。
1105
- // lastView.lines 在 prompt 的 redraw() 中为 dispLines()(含 chip pre 行 / chip prefix 列);
1106
- // chip 模式下点击 chip 区域也走同一算法,与 paintInput 的视协议(段接缝除外)一致。
1107
- const cols = Math.max(1, g.cols - promptW);
1108
- const lineVis = lastView.lines.map((l) => wrapByDisplayWidth(l, cols));
1109
- const flat = [];
1110
- for (const lv of lineVis)
1111
- for (const r of lv)
1112
- flat.push(r);
1113
- const totalVis = flat.length;
1114
- const maxInputRows = Math.max(1, Math.floor(g.rows * 0.4));
1115
- // 起窗偏移:用 lastView 当前光标位置作为锚(与 paintInput 同算法)。
1116
- let curVisLine = 0;
1117
- {
1118
- const clRows = lineVis[lastView.cursorLine] ?? [''];
1119
- let acc = 0;
1120
- for (let i = 0; i < clRows.length; i++) {
1121
- const rw = displayWidth(clRows[i]);
1122
- if (lastView.cursorCol <= acc + rw) {
1123
- curVisLine = i;
1124
- break;
1125
- }
1126
- acc += rw;
1127
- curVisLine = i;
1128
- }
1129
- }
1130
- let curAbs = curVisLine;
1131
- for (let i = 0; i < lastView.cursorLine; i++)
1132
- curAbs += lineVis[i].length;
1133
- const startVis = totalVis > maxInputRows
1134
- ? Math.max(0, Math.min(curAbs - maxInputRows + 1, totalVis - maxInputRows))
1135
- : 0;
1136
- // 点到可视区末行之下(空行)→ 等价"光标在最末可视行的字符串末尾"。
1137
- if (startVis + visRow >= totalVis) {
1138
- const lastFlatIdx = totalVis - 1;
1139
- // 把 lastFlatIdx 反推到 (line, segInLine)
1140
- let lastLine = 0;
1141
- let lastSegInLine = lastFlatIdx;
1142
- while (lastLine < lineVis.length && lastSegInLine >= lineVis[lastLine].length) {
1143
- lastSegInLine -= lineVis[lastLine].length;
1144
- lastLine++;
1145
- }
1146
- if (lastLine >= lineVis.length) {
1147
- lastLine = lineVis.length - 1;
1148
- lastSegInLine = lineVis[lastLine].length - 1;
1149
- }
1150
- // lines[lastLine] 内 display_col = 前 segInLine 段 display_w + 末段 display_w
1151
- let inLineDisplayCol = 0;
1152
- for (let s = 0; s <= lastSegInLine; s++)
1153
- inLineDisplayCol += displayWidth(lineVis[lastLine][s] ?? '');
1154
- return {
1155
- line: lastLine,
1156
- displayCol: inLineDisplayCol,
1157
- flatIdx: lastFlatIdx,
1158
- inSegVis: displayWidth(flat[lastFlatIdx]), // 段末
1159
- };
1160
- }
1161
- // flatIdx = 点击可视段在 flat 中的绝对索引(startVis + visRow)
1162
- const flatIdx = startVis + visRow;
1163
- // 把 flatIdx 反推到 (line, segInLine) — line 给 paintInput 用(同 lines[cursorLine] 行号),
1164
- // segInLine 给 prompt 做 cl/cc 反推用。
1165
- let line = 0;
1166
- let segInLine = flatIdx;
1167
- while (line < lineVis.length && segInLine >= lineVis[line].length) {
1168
- segInLine -= lineVis[line].length;
1169
- line++;
1170
- }
1171
- if (line >= lineVis.length) {
1172
- line = lineVis.length - 1;
1173
- segInLine = lineVis[line].length - 1;
1174
- }
1175
- // lines[line] 内 display_col = 前 segInLine 段 display_w 累加 + 段内显示列。
1176
- let inLineDisplayCol = 0;
1177
- for (let s = 0; s < segInLine; s++)
1178
- inLineDisplayCol += displayWidth(lineVis[line][s] ?? '');
1179
- const seg = flat[flatIdx];
1180
- const segW = displayWidth(seg);
1181
- const inSeg = Math.min(visCol, segW); // 段尾点击容许越界 clip 到段末
1182
- return {
1183
- line,
1184
- displayCol: inLineDisplayCol + inSeg,
1185
- flatIdx,
1186
- inSegVis: inSeg,
1187
- };
1188
- }
1189
- /**
1190
- * 鼠标事件分发(mouse.setHandler 注册)。
1191
- * - 左键(button 0):内容区按下开选区、拖动扩展(触边自动翻页)、释放只更新高亮**不复制**;
1192
- * 纯点击(未拖动)清空旧选区(点别处的常见预期);落在输入行 → 自动进入输入态 + 光标定位到点击位
1193
- * (沿用 windowInputVis 的软折规则做逆解,支持多行 / 中文宽字符;右键 release 落输入行仍贴入)。
1194
- * - 右键(button 2,单击 = press→release 未拖动):落在输入行 → 读剪贴板贴入(仿常见终端"右键粘贴");
1195
- * 落在内容区 → 复制当前选区(若有)到剪贴板后立即清空高亮(视觉确认"已复制",不留旧选区误导),
1196
- * 静默不弹提示;都无对应状态则 no-op。
1197
- * - 滚轮(button&64,由 mouse.ts 解析为 wheel 事件):照常 scrollBy。
1198
- * 选区坐标存绝对缓冲行(viewportAbsStart + 屏行),故翻页 / 追加新内容期间选区锚点仍指向同段文字。
1199
- */
1200
- function handleMouseEvent(e) {
1201
- if (!active)
1202
- return;
1203
- if (overlayMouseHandler && overlayMouseHandler(e))
1204
- return; // overlay(composer 等)先接管,消费即止
1205
- if (e.type === 'wheel') {
1206
- // 滚轮始终可用:面板/picker 期间也允许上下查看 agent 输出,
1207
- // 与 onRunningKey 的 PgUp/PgDn 行为一致;mouseEnabled 仅管选区/拖拽。
1208
- scrollWheel(e.dir);
1209
- return;
1210
- }
1211
- // 输入行左键点击:即使 mouseEnabled=false(如 intervention 面板期间)也允许定位光标,
1212
- // 提升用户体验(点哪插哪)。拖拽/右键/内容区点击仍受 mouseEnabled 限制。
1213
- if (e.button === 0 && e.type === 'press' && isInputRow(e.row)) {
1214
- setInputCursorFromClick(e.row, e.col);
1215
- const pos = inputScreenToInputPos(e.row, e.col);
1216
- if (pos) {
1217
- if (selection) {
1218
- selection = null;
1219
- }
1220
- inputSelection = {
1221
- anchor: { line: pos.line, col: pos.displayCol },
1222
- end: { line: pos.line, col: pos.displayCol },
1223
- dragged: false,
1224
- };
1225
- paintInput(lastView);
1226
- }
1227
- return;
1228
- }
1229
- if (!mouseEnabled)
1230
- return;
1231
- const g = getGeo();
1232
- const col = Math.max(0, e.col - 1); // SGR 报表列 1-based → 显示列 0-based
1233
- // 右键释放:落输入行 → 优先复制当前输入框选区(若有),退化为贴入;落内容区 → 复制选区后清高亮。
1234
- if (e.type === 'release' && e.button === 2) {
1235
- if (isInputRow(e.row)) {
1236
- // 输入框选区存在且真拖动过:复制(不动 text 光标,不改 cl/cc),与内容区"copy 后清高亮"对齐
1237
- const inpSel = normalizeInputSelection();
1238
- if (inpSel) {
1239
- const text = extractInputSelectionText(inpSel);
1240
- inputSelection = null;
1241
- if (lastView)
1242
- paintInput(lastView); // 立刻擦反白,视觉反馈
1243
- if (text)
1244
- copyToClipboard(text);
1245
- return;
1246
- }
1247
- pasteIntoInput();
1248
- return;
1249
- }
1250
- const sel = normalizeSelection();
1251
- if (!sel)
1252
- return;
1253
- const text = extractSelectionText(sel);
1254
- selection = null;
1255
- repaintViewport(); // 复制后清高亮:视觉反馈"已复制",不留旧选区误导
1256
- if (!text)
1257
- return;
1258
- copyToClipboard(text);
1259
- return;
1260
- }
1261
- if (e.button !== 0)
1262
- return; // 其余中/右键 press/drag 不处理(终端原生右键菜单等不受影响)
1263
- if (e.type === 'press') {
1264
- // 输入行左键已在上方 mouseEnabled 检查之前处理(支持 intervention 面板期间点击定位光标)。
1265
- // 此处只处理内容区选区。
1266
- selecting = true;
1267
- const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
1268
- const absLine = screenRowToAbsLine(rowInContent);
1269
- selection = { anchorLine: absLine, anchorCol: col, endLine: absLine, endCol: col, dragged: false };
1270
- // 切内容区选区时清掉输入框旧选区(若有)。
1271
- inputSelection = null;
1272
- repaintViewport();
1273
- repaint(); // 补一次输入区重画:INPUT 态 repaintViewport 把真光标留在内容区续写位,须靠 repaint 把它带回输入框(否则点内容区会现假闪烁光标,见 issue)
1274
- return;
1275
- }
1276
- if (e.type === 'drag') {
1277
- if (inputSelection) {
1278
- // 输入框拖动选区:更新 end,触边不动 scroll(输入框行数有限,无外滚概念);重画底栏。
1279
- const pos = inputScreenToInputPos(e.row, e.col);
1280
- if (pos) {
1281
- if (pos.line !== inputSelection.end.line || pos.displayCol !== inputSelection.end.col) {
1282
- inputSelection.dragged = true;
1283
- inputSelection.end = { line: pos.line, col: pos.displayCol };
1284
- }
1285
- paintInput(lastView);
1286
- }
1287
- return;
1288
- }
1289
- if (!selecting || !selection)
1290
- return;
1291
- // 触边自动翻页:motion 事件持续到达时靠此逐步滚动,把选区扩展到滚出去的历史行。
1292
- if (e.row <= 1)
1293
- scrollBy(WHEEL_LINES);
1294
- else if (e.row >= g.contentBottom)
1295
- scrollBy(-WHEEL_LINES);
1296
- const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
1297
- const absLine = screenRowToAbsLine(rowInContent);
1298
- if (absLine !== selection.endLine || col !== selection.endCol)
1299
- selection.dragged = true;
1300
- selection.endLine = absLine;
1301
- selection.endCol = col;
1302
- repaintViewport();
1303
- repaint(); // 同上:把真光标带回输入框,防拖动选区期间光标停留内容区闪烁
1304
- return;
1305
- }
1306
- // release(左键)
1307
- selecting = false;
1308
- // 输入框 release 优先:未拖动 → 清选区(光标已定好);拖动过 → 留高亮等右键复制
1309
- if (inputSelection) {
1310
- if (!inputSelection.dragged) {
1311
- inputSelection = null;
1312
- if (lastView)
1313
- paintInput(lastView);
1314
- }
1315
- // 拖动过:不操作,留高亮
1316
- return;
1317
- }
1318
- if (!selection)
1319
- return;
1320
- if (!selection.dragged) {
1321
- // 内容区纯点击(未拖动):若落在工具 batch 摘要行上 → 切换展开/折叠;
1322
- // 否则原行为:清选区。batch 反查通过 content lineAt + dynamic import(避免 layout↔batch 循环依赖)。
1323
- const absClick = selection.anchorLine; // 起止同行同列,取任一;未拖动时 line = anchor = end
1324
- void (async () => {
1325
- try {
1326
- const m = await import('./batch.js');
1327
- const entry = m.findEntryByAbsLine(absClick);
1328
- if (entry) {
1329
- // 先清选区再改 buffer:contentInsert/Delete 内会立即 repaintViewport;若此时仍保留
1330
- // 旧绝对行选区,会短暂画出一帧错位高亮,随后二次重画,视觉上就是抖动。
1331
- selection = null;
1332
- m.toggleEntry(entry.batchId, entry.entryIndex, {
1333
- contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
1334
- contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
1335
- contentReplaceLine: (absIdx, line) => contentReplaceLine(absIdx, line),
1336
- });
1337
- repaint();
1338
- return;
1339
- }
1340
- const id = m.findBatchByAbsLine(absClick);
1341
- if (id) {
1342
- selection = null;
1343
- m.toggleBatch(id, {
1344
- contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
1345
- contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
1346
- });
1347
- repaint();
1348
- return;
1349
- }
1350
- }
1351
- catch {
1352
- // batch 不可用(非 TTY 等)→ 走默认清选区路径
1353
- }
1354
- selection = null;
1355
- repaintViewport();
1356
- repaint();
1357
- })();
1358
- return;
1359
- }
1360
- // 拖动过:保留高亮选区供右键复制(不在此处复制,复制交给右键释放分支)。
1361
- repaint(); // 同上:把真光标带回输入框
1362
- }
1363
- /** 回尾(offset=0);仅当原本滚动过才重画(避免每轮 enterRunningMode 闪烁)。 */
1364
- export function resetScroll() {
1365
- if (scrollOffset === 0)
1366
- return;
1367
- scrollOffset = 0;
1368
- repaintViewport();
1369
- repaint();
1370
- }
1371
- /** 发消息轮首短时锁住滚动:吸收 stdin 残留滚轮事件(发消息前后的滚轮惯性 / OS 缓冲延迟到达),
1372
- * 防 resetScroll 回尾后被 onRunningKey 接到的残留事件重新滚上去——致 agent 输出进缓冲、显示在历史区。
1373
- * 锁只挡 scrollBy(滚轮 / PgUp-PgDn);不影响 contentWrite 写屏(offset=0 时照常物理写,agent 输出从底部开始)。
1374
- * 默认 SCROLL_LOCK_MS 后自动解锁;enterInputMode(轮末)也清锁。用户轮中后段仍可上滑(满足"输出时能看历史")。 */
1375
- export function lockScrollToBottom(ms = SCROLL_LOCK_MS) {
1376
- scrollLockUntil = Date.now() + ms;
1377
- }
1378
- /** 解锁(轮末 enterInputMode / 测试用)。 */
1379
- export function unlockScroll() {
1380
- scrollLockUntil = 0;
1381
- }
1382
- /** 轮首滚动锁是否生效(测试用)。 */
1383
- export function isScrollLocked() {
1384
- return Date.now() < scrollLockUntil;
1385
- }
1386
- // ── 状态行(两行式:spinner 行 + model 行)──
1387
- const STATUS_SEP = ' ';
1388
- const STATUS_SEP_W = STATUS_SEP.length;
1389
- /** 通用两段式排版:左段左端对齐 + 空格填充 + 右段右端对齐。pad 至少 1 空格防粘连。 */
1390
- function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
1391
- const pad = Math.max(1, cols - leftW - rightW);
1392
- return `${leftStr}${' '.repeat(pad)}${rightStr}`;
1393
- }
1394
- /** 上线之上那行(spinner 行):左段 = spinner 帧 + 状态文字 + 走时(全部左对齐,紧跟不分离)。
1395
- * 右段仅在滚动回看时显历史指示(右端对齐)。
1396
- * 示例:
1397
- * INPUT: ● 空闲
1398
- * 思考中: ⠹ 思考中… 0.5s
1399
- * 运行心跳: ⠋ 生成中 0.5s
1400
- * 滚动回看: ● 空闲 历史 ↑3 (PgDn 回底) */
1401
- function composeSpinnerLine(status, cols) {
1402
- const spinning = mode === 'running' && runningFrame >= 0;
1403
- const hasSpinner = !!status.spinnerFrame;
1404
- const scrolled = scrollOffset > 0;
1405
- // 走时(仅运行态)
1406
- const elapsed = (mode === 'running' && turnStart != null)
1407
- ? fmtElapsed(Date.now() - turnStart)
1408
- : '';
1409
- // ── 左段:帧 + 状态 + 走时,全部紧跟 ──
1410
- let lead;
1411
- let leadW;
1412
- if (scrolled) {
1413
- // 滚动回看:左段 = 符号(● 或 心跳帧) + 状态名 + (运行态)走时;右段 = 历史指示。
1414
- // 跟非回看态严格对齐——RUNNING 态显示走时,INPUT 态不显示,避免滚动时信息降级。
1415
- const symbol = spinning
1416
- ? `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset}`
1417
- : `${ui.accent}●${ui.reset}`;
1418
- // spinning(运行态流式中 spinner 已停,靠 turnTimer 心跳):同非滚动 spinning 分支加
1419
- // || '生成中' fallback——流式首 token 到即 spinner.stop(),若 stop() 回调未留状态字,
1420
- // 此处兜底不再让 lead 只剩心跳帧字符(用户误读为"左侧蒸发了")。
1421
- const label = spinning ? (status.status || '生成中') : status.status;
1422
- const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1423
- lead = `${symbol} ${ui.dim}${label}${ui.reset}${ePart}`;
1424
- leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1425
- }
1426
- else if (hasSpinner) {
1427
- // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
1428
- const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1429
- lead = `${ui.bold}${ui.accent}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
1430
- leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1431
- }
1432
- else if (spinning) {
1433
- // 运行态心跳帧(流式输出中 / 命令态如 /rollback /compact /resume):帧 + 状态文字(优先)或生成中(兜底) + 走时
1434
- const label = status.status || '生成中';
1435
- const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1436
- lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset} ${ui.dim}${label}${ui.reset}${ePart}`;
1437
- leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1438
- }
1439
- else {
1440
- // INPUT 态:● + 状态文字(无走时)
1441
- lead = `${ui.accent}●${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
1442
- leadW = 1 + 1 + displayWidth(status.status);
1443
- }
1444
- // ── 右段:仅滚动回看时显历史指示 ──
1445
- let tail = '';
1446
- let tailW = 0;
1447
- if (scrolled) {
1448
- tail = t('status.history', { count: scrollOffset });
1449
- tailW = displayWidth(tail);
1450
- }
1451
- const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
1452
- return twoColumn(lead, leadW, rightStr, tailW, cols);
1453
- }
1454
- /** 下线之下那行(model 行):左 = 模式标识 + 本轮 token chip;右 = context + cwd,右端对齐。
1455
- * 活跃 plan chip 不再放这里,改放 spinner 行上方的「虚拟空行」(contentBottom+1,见 drawStatusBar),
1456
- * 既不挤 model 行,又给输入区上方留出可视分隔带。 */
1457
- function composeModelLine(status, cols) {
1458
- const ctx = status.contextBar; // 已带色
1459
- const ctxW = ansiDisplayWidth(ctx);
1460
- // 左段:模式标识 + 切换提示(灰)+ 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
1461
- const modeTag = status.modeTag ?? '';
1462
- const modeColor = modeTag === 'Plan' ? ui.yellow : ui.accent;
1463
- const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
1464
- const modeW = modeTag ? displayWidth(modeTag) : 0;
1465
- // 切换提示:告诉用户怎么切模式。灰(dim)降优先级,不与 modeTag 抢色;只在有 modeTag 时出现。
1466
- const HINT = t('status.modeSwitch');
1467
- const hintW = modeTag ? displayWidth(HINT) : 0;
1468
- const hintPart = modeTag ? `${ui.dim}${HINT}${ui.reset}` : '';
1469
- const tokChip = formatTurnTokenChip(status.lastTurnUsage);
1470
- const tokW = displayWidth(stripAnsi(tokChip));
1471
- // 合并左段:段间留 2 空格分隔。极窄时 hint 与 chip 都可能藏掉。
1472
- // 优先级:modeTag(必) > chip(提示累计 token,有信息量)> hint(纯说明性,窄时最先省)。
1473
- const sepMH = modePart && (hintPart || tokChip) ? ' ' : '';
1474
- const sepHT = (hintPart && tokChip) ? ' ' : '';
1475
- const leftStr = `${modePart}${sepMH}${hintPart}${sepHT}${tokChip}`;
1476
- const leftW = modeW
1477
- + (modePart && (hintPart || tokChip) ? sepMH.length : 0)
1478
- + hintW
1479
- + ((hintPart && tokChip) ? sepHT.length : 0)
1480
- + tokW;
1481
- // 右段:实时用量 chip(仅 RUNNING)+ ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
1482
- // 实时 chip 放 context 进度条左侧:本轮累计 ↑prompt ↓completion,流式实时增长。
1483
- // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再按 hint→chip 顺序省。
1484
- const liveChip = mode === 'running' && liveUsage ? formatLiveUsageChip(liveUsage) : '';
1485
- const liveW = displayWidth(stripAnsi(liveChip));
1486
- const liveSepW = liveChip ? STATUS_SEP_W : 0;
1487
- const minGap = 2;
1488
- let cwdBudget = cols - leftW - minGap - liveW - liveSepW - ctxW - STATUS_SEP_W - 1;
1489
- let cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
1490
- let cwdW = displayWidth(cwd);
1491
- let rightStr = `${liveChip}${liveChip ? STATUS_SEP : ''}${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
1492
- let rightW = liveW + liveSepW + ctxW + STATUS_SEP_W + cwdW;
1493
- // 极窄:逐步降级——先藏 hint,再藏 token chip,只剩 modeTag 与右段挤。
1494
- // 这样 80 列宽终端下 hint 和 chip 都能稳住,只 <50 列才退化到只剩 modeTag。
1495
- if (leftW + minGap + rightW > cols) {
1496
- // 优先藏 hint(纯说明,信息密度最低):仅留 modePart + sep + tokChip
1497
- if (modePart && tokChip && hintPart) {
1498
- const leftStr2 = `${modePart}${sepMH}${tokChip}`;
1499
- const leftW2 = modeW + sepMH.length + tokW;
1500
- if (leftW2 + minGap + rightW <= cols) {
1501
- return twoColumn(leftStr2, leftW2, rightStr, rightW, cols);
1502
- }
1503
- }
1504
- // 再藏 token chip:仅留 modePart
1505
- if (modePart && tokChip) {
1506
- return twoColumn(modePart, modeW, rightStr, rightW, cols);
1507
- }
1508
- }
1509
- return twoColumn(leftStr, leftW, rightStr, rightW, cols);
1510
- }
1511
- /** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。
1512
- * 显示策略:chip 信息密度有限,只显示总量 + 一个 ↻ 标记表示有 cache 命中。
1513
- * 详细分项(↑↓ 计费/↻ 缓存/reasoning)在 turn 末 summary 行展示,不在此处展开。 */
1514
- function formatTurnTokenChip(usage) {
1515
- if (!usage || !usage.totalTokens)
1516
- return '';
1517
- const n = usage.totalTokens;
1518
- const text = n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
1519
- const cached = usage.cachedTokens ?? 0;
1520
- const cacheTag = cached > 0
1521
- ? ` ↻ ${cached < 1000 ? cached : `${(cached / 1000).toFixed(cached >= 10000 ? 0 : 1)}k`}`
1522
- : '';
1523
- // chip 用 mid 灰(降优先级)— 模式仍是主色
1524
- return `${ui.dim}${text} tokens${cacheTag}${ui.reset}`;
1525
- }
1526
- /** 运行态实时用量 chip(放 context 进度条左侧):↑计费prompt(裸-cached,与轮末摘要 (↑…) 同口径)
1527
- * ↓completion,cache 命中带 ↻ 标记;缩写与轮末摘要 / 左侧 turn chip 同款。
1528
- * 流式期为估算值,每步末尾 usage chunk 到达后换实测;dim 灰降优先级,不与进度条阈值警示抢色。 */
1529
- function formatLiveUsageChip(u) {
1530
- const fmt = (n) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`);
1531
- const cached = u.cachedTokens ?? 0;
1532
- const billable = Math.max(0, u.promptTokens - cached);
1533
- const cacheTag = cached > 0 ? ` ↻ ${fmt(cached)}` : '';
1534
- return `${ui.dim}↑ ${fmt(billable)} ↓ ${fmt(u.completionTokens)}${cacheTag}${ui.reset}`;
1535
- }
1536
- /** spinner 行上方的「虚拟空行」(contentBottom+1)。
1537
- * - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow)
1538
- * - 无活跃 plan:空(保留原分隔视觉,避免内容贴输入区)
1539
- * - 过长(> cols):始终**截断保 1 行 + "…"**,不撑高脚栏、不拆 2 行——
1540
- * 步进式任务标题/current step 通常是自然语言,即使切「 ▸ 」拆 2 行,第 2 行也大概率仍超长
1541
- * (实测「第三步:实现核心业务模块。…」本身就比 cols 长),拆完依然覆盖 spinner/输入区,得不偿失。
1542
- * 截断 + "…" 一行内永远不溢出,脚栏恒 6 行,spinner/输入/下线/model 行偏移稳定。
1543
- * 这行在 DECSTBM 滚动区外([1, contentBottom]),稳定不滚。 */
1544
- function composePlanLines(status, cols) {
1545
- const plan = (status.planSummary ?? '').trim();
1546
- if (!plan) {
1547
- planRows = 1;
1548
- return [];
1549
- }
1550
- const w = displayWidth(stripAnsi(plan));
1551
- if (w <= cols) {
1552
- planRows = 1;
1553
- return [`${ui.yellow}${plan}${ui.reset}`];
1554
- }
1555
- // 溢出:截断为 1 行 + "…" 后缀(留 1 列空间给省略号)。
1556
- planRows = 1;
1557
- return [`${ui.yellow}${truncateDisplay(plan, Math.max(1, cols - 1))}…${ui.reset}`];
1558
- }
1559
- /** 画状态行(plan 行 + spinner 行 + model 行,三行)。RUNNING 态 spinner 频繁调。
1560
- * 行号(footerH=6 当 plan 单行;footerH=7 当 plan 撑 2 行):
1561
- * plan 行 = contentBottom+1 (单行)
1562
- * plan 第 2 行 = contentBottom+2 (双行,可选)
1563
- * spinner 行 = contentBottom+1+planRows (● 空闲 / ⠹ 思考中… / etc)
1564
- * 上线 = contentBottom+2+planRows (画在 paintInput)
1565
- * 输入行 = contentBottom+3+planRows
1566
- * 下线 = contentBottom+4+planRows
1567
- * model 行 = rows (屏底:auto + ctx + cwd) */
1568
- export function drawStatusBar(status) {
1569
- if (!active || !base)
1570
- return;
1571
- const s = status ?? { ...base, status: statusText, spinnerFrame };
1572
- const g = getGeo();
1573
- const planLines = composePlanLines(s, g.cols);
1574
- const planRow1 = g.contentBottom + 1;
1575
- const spinnerRow = g.contentBottom + 1 + planRows;
1576
- const modelRow = g.rows; // 屏底:model 行
1577
- // plan 可能占 1 或 2 行(过长自动撑开);spinner/model 行随之平移。
1578
- // 一次写入:plan 1/2 行 + spinner 行 + model 行,末尾 cup 回续写位/输入框光标。
1579
- let planBuf = '';
1580
- for (let i = 0; i < planLines.length; i++) {
1581
- planBuf += cup(planRow1 + i, 1) + esc.clearLine + planLines[i];
1582
- }
1583
- // 计划行从 2 行降到 1 行时,清掉残留的第 2 行(撑高后回退不留尾巴)。
1584
- if (planRows === 1 && g.footerH > 6) {
1585
- planBuf += cup(planRow1 + 1, 1) + esc.clearLine;
1586
- }
1587
- // plan 从非空变成空(已结算为 ## Done:/ notes 里无活跃 ## Plan: 段)时,清掉残留的 plan 行 1。
1588
- // 否则上轮渲染的「plan: 标题 (N/M) ▸ 当前步」会卡在底栏「虚拟空行」位置直到下一次重启 REPL。
1589
- if (planLines.length === 0) {
1590
- planBuf += cup(planRow1, 1) + esc.clearLine;
1591
- }
1592
- let out = planBuf +
1593
- cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(s, g.cols) +
1594
- cup(modelRow, 1) + esc.clearLine + composeModelLine(s, g.cols);
1595
- if (mode === 'running') {
1596
- // 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
1597
- const p = runningCaretPos();
1598
- out += cup(p.row, p.col);
1599
- }
1600
- else {
1601
- out += cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1);
1602
- }
1603
- stdout.write(out);
1604
- }
1605
- /** 更新易变状态(状态文字 + spinner 帧)并重画状态行。spinner / agent 用。 */
1606
- export function setStatus(status, frame) {
1607
- statusText = status;
1608
- spinnerFrame = frame;
1609
- drawStatusBar();
1610
- }
1611
- /**
1612
- * 启走时刷新计时器:RUNNING 态每 80ms 重画状态行,使 composeStatus 重算 elapsed。
1613
- * 必要性:spinner 在首 token 到达即 stop,思考/正文流式期间状态行不再经 spinner 刷新;
1614
- * 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 同速,重叠幂等无妨。
1615
- * 非 TTY 不启(active=false 时 drawStatusBar 为 no-op)。
1616
- */
1617
- function startTurnTimer() {
1618
- if (!active)
1619
- return;
1620
- stopTurnTimer();
1621
- runningFrame = 0; // 进入运行态:启动状态行 chip 心跳(首帧立即生效)
1622
- turnTimer = setInterval(() => {
1623
- runningFrame = (runningFrame + 1) % RUNNING_FRAMES.length; // 推进心跳帧,让前导符跳动
1624
- drawStatusBar();
1625
- }, 80);
1626
- turnTimer.unref();
1627
- }
1628
- /** 停走时计时器。enterInputMode / exitAltScreen 调;intervention 面板进入时也调(防 drawStatusBar 80ms 心跳把光标拉到 runningCaretPos,覆盖 paintInput 的正确光标位)。 */
1629
- export function stopTurnTimer() {
1630
- if (turnTimer) {
1631
- clearInterval(turnTimer);
1632
- turnTimer = null;
1633
- }
1634
- }
1635
- /** 恢复走时计时器(若 RUNNING 态)。intervention 面板退出时调,恢复状态行走时心跳。 */
1636
- export function startTurnTimerIfRunning() {
1637
- if (active && mode === 'running')
1638
- startTurnTimer();
1639
- }
1640
- /**
1641
- * 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
1642
- * 仅 TTY + offset=0(实时尾)+ 非打字暂停态时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
1643
- * 配合 clearLiveAtCursor 在 spinner 停时清掉,随后 contentWrite 的结果即写在该行(spinner 不入历史缓冲)。
1644
- *
1645
- * 记 frameRow/frameCol = 实际画帧位置;clearLiveAtCursor 清"这行"而非当前续写位,防续写位漂移时清错行。
1646
- * 续写位漂移时(运行期间被某次 contentWrite 推进,罕见但 557e678 后可能)先清旧 frameRow 再画新位,避免旧帧残留。
1647
- * 打字暂停态(isStreamingPaused)spinner 不画帧——恢复 557e678 前的 spinner 隐形行为(只关 spinner,不动 contentWrite)。
1648
- */
1649
- export function paintLiveAtCursor(text) {
1650
- if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused()) {
1651
- // 滚动态 / 暂停态:不画新帧。但若有旧帧残留(frameRow),必须清掉——
1652
- // 否则旧 spinner 帧停在历史视图某行,用户看到「思考中」卡在消息堆里(根因)。
1653
- // 滚动态由状态行心跳兜底显示运行状态,不需内联帧;清完后 frameRow 归零,回尾时下一帧自然重画。
1654
- if (frameRow) {
1655
- // resize 后 contentBottom 可能缩小:清旧帧时钳 frameRow 到当前可视区,避免 cup 到屏外行
1656
- const g = getGeo();
1657
- const fr = Math.min(frameRow, g.contentBottom);
1658
- let out = cup(fr, frameCol) + esc.clearLine;
1659
- if (mode === 'running') {
1660
- const p = runningCaretPos();
1661
- out += cup(p.row, p.col);
1662
- }
1663
- stdout.write(out);
1664
- frameRow = 0;
1665
- frameCol = 0;
1666
- }
1667
- return;
1668
- }
1669
- // resize 后 contentRow 可能过时(拖终端框):
1670
- // - 缩小:contentRow > 新 bottom → 钳到新 bottom
1671
- // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total+1, bottom)(内容末尾或新区底)
1672
- // total+1 是合法「待写位」(breakRow 后光标在新空行);用 total 会把续写位拉回最后一行,
1673
- // 首次 contentWrite 覆盖 banner(首条消息「插到 logo 下面」bug)。
1674
- // 否则 cup 到旧行号→帧画在屏幕中间(旧 bottom 位置),而非内容末尾/最底部。
1675
- const g = getGeo();
1676
- const committed = content.committedRows();
1677
- if (contentRow > g.contentBottom)
1678
- contentRow = g.contentBottom;
1679
- if (scrollOffset === 0 && contentRow < g.contentBottom) {
1680
- // 用 committed+1 而非 total+1:breakRow 后 hasCurrent=true,totalRows 已含当前空行,+1 会多跳一行
1681
- // (contentWrite 入口的同类逻辑已同步修正,见上方注释)。
1682
- contentRow = Math.min(committed + 1, g.contentBottom);
1683
- }
1684
- let out = '';
1685
- if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
1686
- // 清旧帧同样钳到可视区(resize 后 frameRow 可能 > contentBottom)
1687
- out += cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
1688
- }
1689
- out += cup(contentRow, contentCol) + esc.clearLine + text;
1690
- if (mode === 'running') {
1691
- const p = runningCaretPos();
1692
- out += cup(p.row, p.col);
1693
- }
1694
- stdout.write(out);
1695
- frameRow = contentRow;
1696
- frameCol = contentCol;
1697
- }
1698
- /** 清掉 paintLiveAtCursor 画过的那行瞬时活动文本。清"实际画过的位置"(frameRow),非当前续写位。
1699
- * 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。
1700
- * 滚动态(scrollOffset≠0)也必须清残留帧——否则旧 spinner 帧卡在历史视图某行(「思考中在消息堆里」根因)。 */
1701
- export function clearLiveAtCursor() {
1702
- if (!active || !ui.isTTY)
1703
- return;
1704
- if (!frameRow)
1705
- return; // 没画过就不清(避免误清当前续写位内容)
1706
- // 滚动态也清:旧帧不该残留。清"画过的行"(frameRow),回尾后该行由 repaintViewport 重画历史内容。
1707
- // resize 后 contentBottom 可能缩小:frameRow 钳到可视区,避免 cup 到屏外行清错位置。
1708
- const g = getGeo();
1709
- let out = cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine;
1710
- if (mode === 'running') {
1711
- const p = runningCaretPos();
1712
- out += cup(p.row, p.col);
1713
- }
1714
- stdout.write(out);
1715
- frameRow = 0;
1716
- frameCol = 0;
1717
- }
1718
- /** 推送 / 清空运行态实时 token 用量(agent core 流式推送;repl 轮末清 undefined)。
1719
- * 不触发重画:RUNNING 态 turnTimer 80ms 心跳重画状态行,自然取到最新值。 */
1720
- export function setLiveUsage(u) {
1721
- liveUsage = u;
1722
- }
1723
- /** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token chip)。repl 在轮次边界与切模式时调。 */
1724
- export function setStatusBase(b) {
1725
- base = b;
1726
- }
1727
- // ── 输入区(底栏输入框 + 向上菜单)──
1728
- /**
1729
- * 把逻辑行软折成可视行并开窗(超 maxInputRows 时滑窗保光标可见)。返回可见可视行文本(已折行)+
1730
- * 光标在窗口内行号 + 光标在该可视行的显示列 + 所需输入行数 + 窗口起始全局可视行号。
1731
- *
1732
- * 光标可视位置按各行**实际**显示宽度逐行累加(不按 width×行号 除法):宽字符(CJK=2)在行尾放不下时
1733
- * 整字折到下行、本行留尾空格,简单除法会把光标算偏;逐行累加 displayWidth 才与终端实际光标一致。
1734
- */
1735
- function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
1736
- const W = Math.max(1, cols - promptW);
1737
- const lineVis = lines.map((l) => wrapByDisplayWidth(l, W));
1738
- const flat = [];
1739
- for (const lv of lineVis)
1740
- for (const r of lv)
1741
- flat.push(r);
1742
- const totalVis = flat.length;
1743
- // 光标在其逻辑行内的可视行 / 可视列
1744
- const clRows = lineVis[cursorLine] ?? [''];
1745
- let curVisRow = 0;
1746
- let curVisCol = cursorCol;
1747
- {
1748
- let acc = 0;
1749
- for (let i = 0; i < clRows.length; i++) {
1750
- const rw = displayWidth(clRows[i]);
1751
- if (cursorCol <= acc + rw) {
1752
- curVisRow = i;
1753
- curVisCol = cursorCol - acc;
1754
- break;
1755
- }
1756
- acc += rw;
1757
- curVisRow = i;
1758
- curVisCol = cursorCol - acc;
1759
- }
1760
- }
1761
- // 光标绝对可视行
1762
- let curAbs = 0;
1763
- for (let i = 0; i < cursorLine; i++)
1764
- curAbs += lineVis[i].length;
1765
- curAbs += curVisRow;
1766
- const maxInputRows = Math.max(1, Math.floor(rows * 0.4));
1767
- let startVis = 0;
1768
- if (totalVis > maxInputRows) {
1769
- startVis = Math.max(0, Math.min(curAbs - maxInputRows + 1, totalVis - maxInputRows));
1770
- }
1771
- const showCount = Math.min(maxInputRows, totalVis);
1772
- return {
1773
- visRows: flat.slice(startVis, startVis + showCount),
1774
- visLine: curAbs - startVis,
1775
- cursorVisCol: curVisCol,
1776
- inputRows: showCount,
1777
- startVis,
1778
- };
1779
- }
1780
- /** 把 lines 按 W 做软折,返回 lineVis;供 paintInput 高亮预算用。与 windowInputVis 复用 wrap 算法。 */
1781
- function visInputLines(lines, W) {
1782
- return lines.map((l) => wrapByDisplayWidth(l, W));
1783
- }
1784
- /**
1785
- * 给纯文本(无 ANSI,来自 wrap 后的可视段)做"显示列区间"高亮,返回插入 SEL_OPEN/SEL_OFF 后的字符串。
1786
- * 输入字段 vis 段宽 = displayWidth(seg),clipStart/clipEnd 单位 = display_col。
1787
- * 与 highlightRange 等价语义但省去 ANSI split(纯文本无 SGR),更省。
1788
- */
1789
- function highlightWithinRow(seg, clipStart, clipEnd) {
1790
- if (clipEnd <= clipStart)
1791
- return seg;
1792
- let out = '';
1793
- let w = 0;
1794
- let opened = false;
1795
- for (const ch of seg) {
1796
- const cw = charWidth(ch.codePointAt(0) ?? 0);
1797
- if (!opened && w >= clipStart && w < clipEnd) {
1798
- out += SEL_OPEN;
1799
- opened = true;
1800
- }
1801
- if (opened && w >= clipEnd) {
1802
- out += SEL_OFF;
1803
- opened = false;
1804
- }
1805
- out += ch;
1806
- w += cw;
1807
- }
1808
- if (opened)
1809
- out += SEL_OFF;
1810
- return out;
1811
- }
1812
- /**
1813
- * 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
1814
- * prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
1815
- */
1816
- export function paintInput(view) {
1817
- if (!active || !base)
1818
- return;
1819
- lastView = view;
1820
- // 失效检测:lastView 文本若有变化(键输入/AI 续写)→ 选区锚点不再有效,清掉避免「错位反白」。
1821
- // 内容区选区用 content 自己的锚,与 lastView 无关,不动。
1822
- if (inputSelection) {
1823
- const sig = inputViewSig(view);
1824
- if (sig !== lastInputSig) {
1825
- inputSelection = null;
1826
- }
1827
- lastInputSig = sig;
1828
- }
1829
- else {
1830
- lastInputSig = inputViewSig(view);
1831
- }
1832
- const preGeo = getGeo();
1833
- // 累积本帧所有 cup/clearLine/文本,末尾一次写出——避免「擦旧菜单」与「重画」分多次 write 时,
1834
- // 终端把中间空白渲染出来 → 菜单/面板切换时闪烁(↑↓ 导航每次 redraw 都全帧擦后重画)。
1835
- let buf = '';
1836
- // 1. 擦旧菜单(用上次记录的起始行 + 行数,与本次几何无关——setRegion 不动屏幕内容)
1837
- if (lastMenuRows > 0) {
1838
- for (let i = 0; i < lastMenuRows; i++) {
1839
- buf += cup(lastMenuStartRow + i, 1) + esc.clearLine;
1840
- }
1841
- lastMenuRows = 0;
1842
- }
1843
- // 2. 算输入可视行(软折行)+ 必要时 setRegion。dim=运行态占位:单行不折行。
1844
- const promptW = displayWidth(view.prompt);
1845
- const vis = view.dim
1846
- ? {
1847
- visRows: [view.lines[0] ?? ''],
1848
- visLine: 0,
1849
- cursorVisCol: 0,
1850
- inputRows: 1,
1851
- startVis: 0,
1852
- }
1853
- : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
1854
- const needFooterH = 5 + vis.inputRows + (planRows - 1); // 1 虚拟空 + 1 spinner 行 + 1 上线 + 输入行 + 1 下线 + 1 model 行 + plan 多出的行数
1855
- let g = preGeo;
1856
- if (needFooterH !== footerH) {
1857
- // setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
1858
- // lastMenuStartRow,须在 setRegion 改区域前落地),再 setRegion,之后 2b..6 重新累积。
1859
- // footerH 不变时(常见:单行输入 / 菜单切换 / ↑↓ 导航)整帧一次 write,终端原子应用,无中间空白→不闪烁。
1860
- if (buf) {
1861
- stdout.write(buf);
1862
- buf = '';
1863
- }
1864
- g = setRegion(needFooterH);
1865
- }
1866
- // 2b. 重画内容末行(底栏上一行)从缓冲:防 WT 边距漏影(状态行重复到该行),并保证该行显正确内容
1867
- {
1868
- const slice = content.sliceFromEnd(scrollOffset, g.contentBottom);
1869
- const line = slice[g.contentBottom - 1] ?? '';
1870
- buf += cup(g.contentBottom, 1) + esc.clearLine + line;
1871
- }
1872
- // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):
1873
- // - 无活跃 plan:清空保留作分隔(原设计)
1874
- // - 有活跃 plan:渲染 plan chip(整帧重画时也要更新,避免 listener 漏触发后残留)
1875
- // - plan 过长:占 2 行(planRows=2),spinner/上线/输入行随之整体下移 1 行
1876
- // paintInput 与 drawStatusBar 共享同一 planRows 模块态,setRegion 已据此把脚栏撑高。
1877
- {
1878
- const planBuf = composePlanLines(base, preGeo.cols);
1879
- for (let i = 0; i < planBuf.length; i++) {
1880
- buf += cup(g.contentBottom + 1 + i, 1) + esc.clearLine + planBuf[i];
1881
- }
1882
- // 计划行从 2 行降到 1 行(plan 当前很短):清掉残留的第 2 行,不留尾巴。
1883
- if (planBuf.length < planRows && planRows === 2) {
1884
- buf += cup(g.contentBottom + 2, 1) + esc.clearLine;
1885
- planRows = 1;
1886
- }
1887
- // plan 从非空变成空(已结算/无活跃段)时,清掉残留的 plan 行 1。
1888
- // 上面 for 循环在 planBuf.length===0 时不写任何 cup,需要显式清一行回到「虚拟空行」。
1889
- if (planBuf.length === 0) {
1890
- buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
1891
- }
1892
- }
1893
- // 3. 状态行:spinner 行 + model 行(两行式底栏)
1894
- const spinnerRow = g.contentBottom + 1 + planRows; // plan 占 planRows 行(1 或 2),spinner 紧跟其后
1895
- const modelRow = g.rows; // 屏底:model 行
1896
- const status = { ...base, status: statusText, spinnerFrame };
1897
- buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
1898
- buf += cup(modelRow, 1) + esc.clearLine + composeModelLine(status, g.cols);
1899
- // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
1900
- buf += cup(g.contentBottom + 2 + planRows, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
1901
- // 4. 输入行(g.contentBottom+3+planRows .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
1902
- const firstInputRow = g.contentBottom + 3 + planRows;
1903
- const inputRowsAvail = vis.inputRows; // 脚栏总高 - 1(spinner) - 1(上 sep) - 1(下 sep) - 1(model) - planRows
1904
- const indent = ' '.repeat(promptW);
1905
- // 光标:不画反白块/假光标——输入框走终端真光标(WT / VSCode 终端默认竖线/闪烁块,
1906
- // 各终端表现略不同但都贴合 IME 候选气泡且不再"挡住字符")。真光标位置由下方第 6 步 cup 写。
1907
- // 保留 view.caret=false 路径给 picker 等非文本输入:把真光标定位到 hint 末尾。
1908
- const inpSel = normalizeInputSelection(); // 已签名检测失效,这里看到的要么有效要么 null
1909
- // 预算 (visRow -> line) 用于高亮:与 inputScreenToInputPos 的反推同源(flat → line + segInLine)
1910
- const colW = Math.max(1, preGeo.cols - promptW);
1911
- const lineVisArr = visInputLines(view.lines, colW);
1912
- const flatInput = [];
1913
- for (const lv of lineVisArr)
1914
- for (const r of lv)
1915
- flatInput.push(r);
1916
- let cumLine = 0;
1917
- const flatToLine = [];
1918
- for (let li = 0; li < lineVisArr.length; li++) {
1919
- const segs = lineVisArr[li];
1920
- let acc = 0;
1921
- for (let s = 0; s < segs.length; s++) {
1922
- flatToLine.push({ line: li, segInLine: s, inLineDisplayCol: acc });
1923
- acc += displayWidth(segs[s]);
1924
- }
1925
- if (segs.length === 0)
1926
- flatToLine.push({ line: li, segInLine: 0, inLineDisplayCol: 0 });
1927
- }
1928
- for (let i = 0; i < inputRowsAvail; i++) {
1929
- const line = vis.visRows[i] ?? '';
1930
- const r = firstInputRow + i;
1931
- const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
1932
- let text;
1933
- if (view.dim) {
1934
- text = renderDimInputRow(view.prompt, line, view.placeholder ?? '', g.cols);
1935
- }
1936
- else {
1937
- text = `${prefix}${line}`;
1938
- // 空输入引导:INPUT 态缓冲为空时,在首行画 dim ghost 占位(「输入 / 查看命令…」);
1939
- // 任何按键都会改变缓冲 → 下一帧 isEmpty=false → 占位自然消失,无需额外擦除逻辑。
1940
- if (!line && view.placeholder && vis.startVis === 0 && i === 0) {
1941
- const avail = Math.max(0, preGeo.cols - promptW);
1942
- text = `${prefix}${ui.dim}${truncateDisplay(view.placeholder, avail)}${ui.reset}`;
1943
- }
1944
- // 输入框反白叠层:仅当 paintInput 不是 dim(运行态 typeahead 不参与选区,避免干扰 IME 气泡),
1945
- // 且该可视段所属的逻辑行落在 inpSel 区间内 → 高亮行内 [colStart,colEnd) 段。
1946
- if (inpSel) {
1947
- const flatIdx = vis.startVis + i;
1948
- const m = flatIdx < flatToLine.length ? flatToLine[flatIdx] : flatToLine[flatToLine.length - 1];
1949
- if (m.line >= inpSel.startLine && m.line <= inpSel.endLine) {
1950
- const seg = flatInput[flatIdx] ?? '';
1951
- const segW = displayWidth(seg);
1952
- const colStart = m.line === inpSel.startLine ? inpSel.startCol - m.inLineDisplayCol : 0;
1953
- const colEnd = m.line === inpSel.endLine ? inpSel.endCol - m.inLineDisplayCol : segW;
1954
- if (colStart < segW && colEnd > colStart) {
1955
- // 对 seg(纯文本无 ANSI)做行内高亮:在 [clipStart, clipEnd) 之间插 SEL_OPEN/SEL_OFF。
1956
- text = `${prefix}${highlightWithinRow(seg, Math.max(0, colStart), Math.min(segW, colEnd))}`;
1957
- }
1958
- }
1959
- }
1960
- }
1961
- buf += cup(r, 1) + esc.clearLine + text;
1962
- }
1963
- // 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
1964
- buf += cup(g.rows - 1, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
1965
- // 5. 向上菜单(画在内容区底,底栏正上方)
1966
- if (view.menu && view.menu.lines.length > 0) {
1967
- const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
1968
- const menuStart = g.contentBottom - menuRows + 1;
1969
- for (let i = 0; i < menuRows; i++) {
1970
- buf += cup(menuStart + i, 1) + esc.clearLine + view.menu.lines[i];
1971
- }
1972
- lastMenuStartRow = menuStart;
1973
- lastMenuRows = menuRows;
1974
- }
1975
- // 6. 光标
1976
- if (view.dim) {
1977
- // 运行态(回尾 / 滚动回看均):真光标归输入框光标位(供 IME 锚定,气泡在输入框而非内容区)。
1978
- // 滚动态也归输入框——上滑看历史时打字,IME 候选气泡须锚到输入框(conhost IME 不跟随 cup 后续移动,
1979
- // 须让打字前光标已在输入框);viewport 锁历史靠 scrollOffset,不靠光标位置。
1980
- const p = runningCaretPos();
1981
- buf += cup(p.row, p.col);
1982
- }
1983
- else {
1984
- const r = firstInputRow + vis.visLine;
1985
- const col = promptW + vis.cursorVisCol + 1;
1986
- buf += cup(r, col);
1987
- }
1988
- if (buf)
1989
- stdout.write(buf); // 整帧一次写出(footerH 不变时):终端原子应用,无中间空白→不闪烁
1990
- }
1991
- /**
1992
- * 渲染运行态输入行(dim):❯ + (有打字:dim 文本 + 末尾反白块状光标;无打字:反白光标在起点 + dim 占位 ghost)。
1993
- * 光标是「画出来」的反白块(非真终端光标)——真光标仍归续写位,避开与 contentWrite/drawStatusBar 争用;
1994
- * 输入行在 RUNNING 期间只被按键回显重画(流式/spinner 不碰这行),故画上去的光标能稳住。
1995
- * 内容截断到 cols-promptW-1(留 ❯ 与光标各 1 cell),防超长软折行写穿下线。
1996
- */
1997
- function renderDimInputRow(prompt, text, placeholder, cols) {
1998
- const promptW = displayWidth(prompt);
1999
- // dim 态(运行中打字):不画反白块——反白块视觉占位是 INPUT 态真光标的旧版,这里真光标本
2000
- // 就被隐起来(IME 锚定需要,见 contentMode),在 dim 文本后硬塞个反白空格会"白块闪烁"。
2001
- // 用户读 dim 文本本身就能定位打字边界;真光标在 INPUT 态显形(竖线/闪烁块,跟终端默认一致)。
2002
- const contentW = Math.max(0, cols - promptW);
2003
- if (text.length > 0) {
2004
- // 有打字:❯ dim + 文本(dim,超长从头部截断保留尾部——光标恒在末尾,须始终看到刚打的字)
2005
- return `${ui.dim}${prompt}${truncateDisplayHead(text, contentW)}${ui.reset}`;
2006
- }
2007
- // 空:❯ dim + dim 占位 ghost(taking the full row)
2008
- const p = placeholder ? truncateDisplay(placeholder, contentW) : '';
2009
- return `${ui.dim}${prompt}${p}${ui.reset}`;
2010
- }
2011
- /**
2012
- * 单行运行态滑窗:以光标为中心,向左右扩展填满 contentW-1(留 1 cell 给光标),
2013
- * 返回可见子串与光标在子串内的显示列。保证光标恒可见,且不软折行(运行态输入框恒单行,
2014
- * 不触发 setRegion/ED,避免流式期间底栏抖动)。
2015
- */
2016
- function windowSingleLine(text, cursor, contentW) {
2017
- const n = text.length;
2018
- if (n === 0)
2019
- return { shown: '', curDisp: 0 };
2020
- const totalDisp = displayWidth(text);
2021
- if (totalDisp <= contentW)
2022
- return { shown: text, curDisp: displayWidth(text.slice(0, cursor)) };
2023
- let i = cursor;
2024
- let j = cursor;
2025
- let w = 0;
2026
- const cw = (idx) => charWidth(text.codePointAt(idx) ?? 0);
2027
- while (i > 0 && w + cw(i - 1) <= contentW - 1) {
2028
- w += cw(i - 1);
2029
- i--;
2030
- }
2031
- while (j < n && w + cw(j) <= contentW - 1) {
2032
- w += cw(j);
2033
- j++;
2034
- }
2035
- return { shown: text.slice(i, j), curDisp: displayWidth(text.slice(i, cursor)) };
2036
- }
2037
- /**
2038
- * 运行态 typeahead 回显:定向写输入行(底栏输入框),非 dim(与空闲态同色)、光标可任意位置、单行。
2039
- * 只 cup 输入行 + clearLine + 文本 + cup 真光标到编辑位——不调 setRegion(运行中禁多行,避免 DECSTBM 抖动)、
2040
- * 不重画状态行/contentBottom、不用 ED。同步 lastView 为运行视图(非 dim),使 scrollBy/resize 的 repaint
2041
- * 仍显当前回显 + 光标。空且带 placeholder 时显 dim ghost(提示 agent 状态;非用户输入,不违反"打字不变灰")。
2042
- * 选区高亮由 paintInput 路径(paint 时鼠标拖选)承担,此处轻量不重复实现。
2043
- */
2044
- export function paintRunningInput(text, cursor, placeholder) {
2045
- if (!active || !base)
2046
- return;
2047
- const g = getGeo();
2048
- const inputRow = g.contentBottom + 3 + planRows; // 运行态 footerH 随 plan 行数动态(6 或 7)
2049
- const promptW = displayWidth('❯ ');
2050
- const contentW = Math.max(1, g.cols - promptW);
2051
- let outLine;
2052
- let curCol; // 输入框内的显示列(不含 prompt)
2053
- if (text.length === 0 && placeholder) {
2054
- outLine = `${ui.dim}❯ ${truncateDisplay(placeholder, contentW)}${ui.reset}`;
2055
- curCol = 0;
2056
- }
2057
- else {
2058
- const { shown, curDisp } = windowSingleLine(text, cursor, contentW);
2059
- outLine = `❯ ${shown}`; // 正常色 —— 运行态打字与空闲态一致
2060
- curCol = curDisp;
2061
- }
2062
- lastView = {
2063
- prompt: '❯ ',
2064
- lines: [text],
2065
- placeholder,
2066
- cursorLine: 0,
2067
- cursorCol: cursor,
2068
- menu: null,
2069
- // 不置 dim:运行态输入框与空闲态同色、可任意位置编辑
2070
- };
2071
- const cursorCol = Math.min(g.cols, promptW + curCol + 1);
2072
- stdout.write(cup(inputRow, 1) + esc.clearLine + outLine + cup(inputRow, cursorCol));
2073
- }
2074
- /** 重画当前视图(resize / 内部用)。 */
2075
- export function repaint() {
2076
- if (!active || !base)
2077
- return;
2078
- if (lastView)
2079
- paintInput(lastView);
2080
- else
2081
- drawStatusBar();
2082
- }
2083
- /** 进入输入态:画空输入框 + 状态行,光标入输入框。 */
2084
- export function enterInputMode(status = t('repl.idle')) {
2085
- mode = 'input';
2086
- statusText = status;
2087
- spinnerFrame = undefined;
2088
- runningFrame = -1; // 回 INPUT 态:停状态行 chip 旋转,composeStatus 退回静态 ●
2089
- turnStart = null; // 停走时
2090
- stopTurnTimer();
2091
- scrollLockUntil = 0; // 轮末:清轮首滚动锁,INPUT 态可自由滚动
2092
- frameRow = 0; // 轮末:清 spinner 帧位置(防下轮残留)
2093
- frameCol = 0;
2094
- // 运行态若有未 flush 的缓冲内容(用户打字暂停了流式写),切回 INPUT 前重画内容区显示之,免丢内容
2095
- if (flushTimer) {
2096
- clearTimeout(flushTimer);
2097
- flushTimer = null;
2098
- }
2099
- if (userActiveUntil) {
2100
- userActiveUntil = 0;
2101
- if (active)
2102
- repaintViewport();
2103
- }
2104
- if (active && base) {
2105
- // 先按当前 base.planSummary 重算 planRows(可能从上次会话残留 stale 值),再据此 setRegion
2106
- // 撑出正确脚栏高;否则 plan 撑 2 行时 setRegion(6) 会把 spinner 挤到 plan 第 2 行位置。
2107
- composePlanLines(base, (getGeo()).cols);
2108
- setRegion(4 + planRows + 1); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行 + plan 多出的行
2109
- paintInput({
2110
- prompt: '❯ ',
2111
- lines: [''],
2112
- cursorLine: 0,
2113
- cursorCol: 0,
2114
- menu: null,
2115
- });
2116
- stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
2117
- }
2118
- }
2119
- /** 进入运行态:底栏输入行与空闲态同色(非 dim)、可任意位置编辑,cursor 留输入框。footerH 恒 6(虚拟空+spinner行+上线+输入+下线+model行)。新轮回尾(确保新内容可见)。 */
2120
- export function enterRunningMode(status, placeholder) {
2121
- mode = 'running';
2122
- statusText = status;
2123
- spinnerFrame = undefined;
2124
- turnStart = Date.now(); // 起走时(整轮从发起到 enterInputMode 止)
2125
- resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
2126
- lockScrollToBottom(); // 轮首短时锁:吸收发消息前后残留滚轮事件,保 agent 输出从底部开始(锁过期或轮末 enterInputMode 解)
2127
- if (active && base) {
2128
- // 先按当前 base.planSummary 重算 planRows(可能从上次会话残留 stale 值),再据此 setRegion
2129
- // 撑出正确脚栏高;否则 plan 撑 2 行时 setRegion(6) 会把 spinner 挤到 plan 第 2 行位置。
2130
- composePlanLines(base, (getGeo()).cols);
2131
- setRegion(4 + planRows + 1); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行 + plan 多出的行
2132
- paintInput({
2133
- prompt: '❯ ',
2134
- lines: [''],
2135
- placeholder,
2136
- cursorLine: 0,
2137
- cursorCol: 0,
2138
- menu: null,
2139
- });
2140
- startTurnTimer(); // 续刷状态行走时(流式期间 spinner 停转,由它兜底)
2141
- contentMode();
2142
- // 真光标可见,停在输入框光标位(供 IME 锚定气泡到光标处)。打字时 setUserActive 暂停流式写、
2143
- // 光标稳定在输入框;流式写时瞬时到 contentRow 但合并写入很快回输入框(光标跟流式是终端常态)。
2144
- // 不再藏光标:藏了 IME 气泡会跑到输入框最右边而非光标处(WT 不锚定隐藏光标)。
2145
- }
2146
- }
2147
- // ── alt screen 生命周期 + 钩子 ──
2148
- export function enterAltScreen() {
2149
- if (active || !ui.isTTY)
2150
- return;
2151
- active = true;
2152
- setMaxCols(getGeo().cols); // 同步 batch 展开行宽钳制,防超宽行 auto-wrap 打乱屏位
2153
- stdout.write(esc.altOn);
2154
- // 不动终端窗口背景:底色保持用户终端原色,主题只作用于我们画出的 SGR 颜色。
2155
- stdout.write(esc.mouseOn); // 完整鼠标追踪(按下/拖动/释放/滚轮)→ mouse.swallow 重组 → handleMouseEvent
2156
- mouse.setHandler(handleMouseEvent);
2157
- // 进入 alt screen 前 base 可能已设了 planSummary;按当前 planSummary 重算 planRows,
2158
- // 让首次 setRegion 撑出正确脚栏高(否则 plan 撑 2 行时会被 spinner 行覆盖)。
2159
- if (base)
2160
- composePlanLines(base, (getGeo()).cols);
2161
- setRegion(4 + planRows + 1); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行 + plan 多出的行
2162
- contentRow = 1;
2163
- contentCol = 1;
2164
- segmentStartRow = 1;
2165
- scrollOffset = 0;
2166
- scrollLockUntil = 0;
2167
- mdActive = false;
2168
- mdBuf = '';
2169
- selection = null;
2170
- selecting = false;
2171
- content.reset();
2172
- exitHandler = () => exitAltScreen();
2173
- process.on('exit', exitHandler);
2174
- sigwinchHandler = () => {
2175
- if (!active)
2176
- return;
2177
- // 立即(同步)更新行号 + 重设 DECSTBM 区域:不 debounce,否则快速拖动边框时
2178
- // contentRow 停在旧值、区域未更新,spinner/contentWrite 画到旧行号(「思考中在消息堆里」根因)。
2179
- // 重画 repaintViewport 防抖(下面 timer),避免连续拖动闪烁;但行号/区域必须立即正确。
2180
- const g = getGeo(footerH);
2181
- setMaxCols(g.cols); // 列宽变 → 展开行钳宽上限同步(后续新展开行生效)
2182
- const total = content.totalRows();
2183
- const committed = content.committedRows();
2184
- // 缩小:contentRow > 新 bottom → 钳到新 bottom
2185
- if (contentRow > g.contentBottom)
2186
- contentRow = g.contentBottom;
2187
- // 放大:contentRow < 新 bottom 且回尾(offset=0)→ 推进到 min(committed+1, bottom)
2188
- // committed+1 是合法「待写位」;用 total+1 在 hasCurrent=true(breakRow 后)时会多跳一行。
2189
- if (scrollOffset === 0 && contentRow < g.contentBottom) {
2190
- contentRow = Math.min(committed + 1, g.contentBottom);
2191
- }
2192
- if (frameRow && frameRow > g.contentBottom)
2193
- frameRow = g.contentBottom;
2194
- const maxOff = Math.max(0, total - g.contentBottom);
2195
- if (scrollOffset > maxOff)
2196
- scrollOffset = maxOff;
2197
- setRegion(footerH); // 用新 rows 立即重设 DECSTBM 区域(contentBottom 变)
2198
- // 重画防抖(仅 repaint,避免快速拖动闪烁);行号/区域已立即更新
2199
- if (resizeTimer)
2200
- clearTimeout(resizeTimer);
2201
- resizeTimer = setTimeout(() => {
2202
- resizeTimer = null;
2203
- if (!active)
2204
- return;
2205
- repaintViewport(); // 内容区按新高度 + 当前 offset 重画(物理行不 reflow)
2206
- repaint(); // 底栏重画
2207
- }, 100);
2208
- resizeTimer?.unref?.();
2209
- };
2210
- process.on('SIGWINCH', sigwinchHandler);
2211
- installConsoleGuard(); // 进入 TUI 即接管裸 console 输出,防第三方日志污染输入框
2212
- }
2213
- /** 复原:复位 margins + 显光标 + 退 alt + 还原 raw。幂等。 */
2214
- export function exitAltScreen() {
2215
- if (!active)
2216
- return;
2217
- active = false;
2218
- uninstallConsoleGuard(); // 退出 TUI 恢复原始 console(不影响 host 子进程 / 退出日志)
2219
- resetTerminalBackground();
2220
- stopTurnTimer(); // 兜底清走时计时器(防异常退出泄漏)
2221
- turnStart = null;
2222
- scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
2223
- frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
2224
- frameCol = 0;
2225
- mouse.setHandler(null);
2226
- mouse.resetMouse();
2227
- selection = null;
2228
- selecting = false;
2229
- // raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
2230
- try {
2231
- stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
2232
- }
2233
- catch {
2234
- // 非 TTY / 不支持:忽略
2235
- }
2236
- try {
2237
- stdout.write('\x1B[r'); // 复位 DECSTBM margins
2238
- stdout.write(esc.cursorShow);
2239
- stdout.write(esc.mouseOff); // 关鼠标追踪(反序:先 1006l 再 1002l 再 1000l)
2240
- stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
2241
- }
2242
- catch {
2243
- // 忽略
2244
- }
2245
- if (exitHandler) {
2246
- try {
2247
- process.removeListener('exit', exitHandler);
2248
- }
2249
- catch {
2250
- // 忽略
2251
- }
2252
- exitHandler = null;
2253
- }
2254
- if (sigwinchHandler) {
2255
- try {
2256
- process.removeListener('SIGWINCH', sigwinchHandler);
2257
- }
2258
- catch {
2259
- // 忽略
2260
- }
2261
- sigwinchHandler = null;
2262
- }
2263
- if (resizeTimer) {
2264
- clearTimeout(resizeTimer);
2265
- resizeTimer = null;
2266
- }
2267
- if (flushTimer) {
2268
- clearTimeout(flushTimer);
2269
- flushTimer = null;
2270
- }
2271
- userActiveUntil = 0;
2272
- }
2273
- /**
2274
- * 直写终端(绕过 content buffer):仅限弹窗类覆盖层(输入面板)使用——
2275
- * 它画在 content 区之上,关闭后由调用方 repaintViewport() 整幅还原。
2276
- * 不做行宽钳制:调用方自己保证每行 ≤ cols(弹窗几何已按 getGeo() 计)。
2277
- */
2278
- export function writeDirect(s) {
2279
- try {
2280
- stdout.write(s);
2281
- }
2282
- catch {
2283
- // 忽略
2284
- }
2285
- }
2286
- export function isActive() {
2287
- return active;
2288
- }
1
+ // 桶文件:保持对外 import 路径不变(from '../ui/layout.js')
2
+ // 实际实现已移到 ui/layout-internal/core.ts,类型定义在 ui/layout-types.ts
3
+ export * from './layout-internal/core.js';