mocode-ai 0.1.2 → 0.1.4

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.
@@ -4,7 +4,7 @@ import { getSkillBody } from '../../skills/index.js';
4
4
  // 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
5
5
  export const useSkillTool = {
6
6
  name: 'use_skill',
7
- description: '加载并返回某个 skill 的完整 SKILL.md 指令。系统提示里列出了可用 skill(name + 何时用的 description)。只在任务相关时调用本工具传 skill 的 name,拿到完整指令后据此行动;不要无脑批量加载。',
7
+ description: '加载并返回某个 skill 的完整 SKILL.md 指令(传 name)。何时用见系统提示的 skill 列表。',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
@@ -4,7 +4,7 @@ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
4
4
  // ---------- web_fetch ----------
5
5
  export const webFetchTool = {
6
6
  name: 'web_fetch',
7
- description: '抓取指定 URL 的网页内容并清洗成纯文本(去 HTML 标签/脚本/样式,保留正文)。用于读取搜索结果里的某个链接、或用户给出的具体 URL。注意:只能抓静态 HTML,JS 渲染的页面(正文靠脚本填充)可能拿不到内容——那种情况改用 web_search(其结果自带清洗后的 content)。',
7
+ description: '抓取指定 URL 的网页内容并清洗成纯文本(去 HTML 标签/脚本/样式,保留正文)。用于读取搜索结果里的某个链接、或用户给出的具体 URL。',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
@@ -6,7 +6,7 @@ const MAX_CONTENT_CHARS = 800;
6
6
  // ---------- web_search ----------
7
7
  export const webSearchTool = {
8
8
  name: 'web_search',
9
- description: '联网搜索(AnySearch)。返回每条结果的标题/URL/摘要/正文,用于查模型训练数据之外的最新信息(新版本发布、新闻、实时数据、最新 API 文档等)。可选 tag 切换子域:general.general(默认通用网页)/ code.doc(开发文档,须配 params.library)/ code.snippet(代码片段)。',
9
+ description: '联网搜索(AnySearch)。返回每条结果的标题/URL/摘要/正文。可选 tag 切换子域( tag 参数)。',
10
10
  parameters: {
11
11
  type: 'object',
12
12
  properties: {
@@ -8,4 +8,17 @@ export const MAX_HISTORY_RESULT = 8000;
8
8
  export const MAX_SKILL_RESULT = 64000;
9
9
  /** 微压缩时旧工具结果截到的存根长度(字符)。 */
10
10
  export const MAX_OLD_TOOL_STUB = 600;
11
+ // ── 记忆(Tier-2 JSONL 工具库)──────────────────────────────────────────────
12
+ /** 单条记忆 body 上限(字符)。进 history 前 memory_search 结果另有 MAX_MEMORY_RESULT 兜底。 */
13
+ export const MAX_MEMORY_ENTRY = 4000;
14
+ /** 启动注入 systemPrompt 的索引条目上限(遗忘封顶后 active 条目 ≤ MAX_ACTIVE,索引再封一层)。 */
15
+ export const MAX_INDEX_ENTRIES = 50;
16
+ /** active 记忆封顶:超则按 recallCount 低 × lastRecalledAt 老 淘汰到 archived。 */
17
+ export const MAX_ACTIVE = 100;
18
+ /** 衰减:active + !pinned + (lastRecalledAt|createdAt) 早于 DECAY_DAYS → archived。 */
19
+ export const DECAY_DAYS = 30;
20
+ /** GC:archived 超 GC_DAYS → 硬删。 */
21
+ export const GC_DAYS = 90;
22
+ /** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
23
+ export const MAX_MEMORY_RESULT = 64000;
11
24
  export const IGNORE = ['**/node_modules/**', '**/.git/**'];
@@ -0,0 +1,297 @@
1
+ import readline from 'node:readline';
2
+ import { stdin, stderr } from 'node:process';
3
+ import { ui } from './theme.js';
4
+ import { displayWidth, truncateDisplay } from './render.js';
5
+ import * as layout from './layout.js';
6
+ import { Spinner } from './spinner.js';
7
+ const emitter = stdin;
8
+ /** choice 末尾的"自定义输入"项标签(纯 ASCII,宽度安全)。选中后切到 input 子态。 */
9
+ const CUSTOM_LABEL = '其他(自定义输入)';
10
+ /** 弹出介入面板,阻塞直到用户完成选择。 */
11
+ export async function promptIntervention(req) {
12
+ // 非 TTY 降级:不阻塞,自动选默认。日志走 stderr(不污染 stdout 内容流)。
13
+ if (!layout.isActive()) {
14
+ const kind = req.type === 'choice' ? '自动选默认项' : '自动返回空输入';
15
+ stderr.write(`[介入] ${req.title}(非交互环境,${kind})\n`);
16
+ if (req.type === 'choice') {
17
+ return { action: 'selected', value: req.options?.[0] ?? '' };
18
+ }
19
+ return { action: 'submitted', value: req.seed ?? '' };
20
+ }
21
+ const options = req.type === 'choice' && Array.isArray(req.options)
22
+ ? req.options.map((o) => String(o)).filter((s) => s.length > 0)
23
+ : [];
24
+ // choice 但选项被滤空 → 降级 input(对齐设计文档 §8:ask_human 选项为空数组→input)。
25
+ const startMode = req.type === 'choice' && options.length > 0 ? 'choice' : 'input';
26
+ let mode = startMode;
27
+ // choice:选中下标(0..options.length-1 为各选项,options.length 为"自定义"项)。
28
+ let selected = 0;
29
+ // input:可编辑文本 + 光标(UTF-16 码元索引;显示位置由 displayWidth 算,见 paintInput)。
30
+ let text = req.seed ?? '';
31
+ let cursor = text.length;
32
+ // input 是否由 choice 的"自定义"项切入(决定 Esc 是返回 choice 还是取消)。
33
+ let cameFromChoice = false;
34
+ let resolved = false;
35
+ let resolve;
36
+ // 挂自己监听前快照的现有 keypress 监听(运行态即 onRunningKey),退出时按原序恢复。
37
+ let savedListeners = [];
38
+ /** choice 菜单行:标题(bold)+ detail(dim,行数上限保选项可见)+ 空行 + 选项(▸ 选中/dim)。 */
39
+ function menuLinesChoice() {
40
+ const g = layout.getGeo();
41
+ const cols = g.cols;
42
+ const items = [...options, CUSTOM_LABEL];
43
+ const optionCount = items.length;
44
+ // detail 上限:title(1)+detail(D)+空行(1)+options(O) ≤ contentBottom → D ≤ contentBottom-2-O
45
+ const detailCap = Math.max(0, g.contentBottom - 2 - optionCount);
46
+ const lines = [];
47
+ lines.push(`${ui.bold}${truncateDisplay(req.title, cols)}${ui.reset}`);
48
+ if (req.detail) {
49
+ const dl = req.detail.split('\n');
50
+ const shown = dl.slice(0, detailCap);
51
+ for (const d of shown) {
52
+ lines.push(`${ui.dim}${truncateDisplay(d, cols)}${ui.reset}`);
53
+ }
54
+ if (dl.length > detailCap)
55
+ lines.push(`${ui.dim}…${ui.reset}`);
56
+ }
57
+ lines.push(''); // 分隔空行
58
+ // 选项开窗:超屏高时以 selected 为中心取窗,保选中项可见。
59
+ const maxOptRows = Math.max(1, g.contentBottom - lines.length);
60
+ let start = 0;
61
+ if (optionCount > maxOptRows) {
62
+ start = Math.max(0, Math.min(selected - Math.floor(maxOptRows / 2), optionCount - maxOptRows));
63
+ }
64
+ const count = Math.min(maxOptRows, optionCount);
65
+ for (let i = 0; i < count; i++) {
66
+ const idx = start + i;
67
+ // 选中项:▸ 与正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
68
+ const isSel = idx === selected;
69
+ const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
70
+ const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
71
+ const body = truncateDisplay(items[idx], cols - 2);
72
+ lines.push(`${marker} ${color}${body}${ui.reset}`);
73
+ }
74
+ return lines;
75
+ }
76
+ /** input 菜单行:标题(bold)+ detail(dim)+ 提示行(dim)。可编辑文本在底栏输入框。 */
77
+ function menuLinesInput() {
78
+ const g = layout.getGeo();
79
+ const cols = g.cols;
80
+ const detailCap = Math.max(0, g.contentBottom - 2); // title(1)+提示(1)
81
+ const lines = [];
82
+ lines.push(`${ui.bold}${truncateDisplay(req.title, cols)}${ui.reset}`);
83
+ if (req.detail) {
84
+ const dl = req.detail.split('\n');
85
+ const shown = dl.slice(0, detailCap);
86
+ for (const d of shown) {
87
+ lines.push(`${ui.dim}${truncateDisplay(d, cols)}${ui.reset}`);
88
+ }
89
+ if (dl.length > detailCap)
90
+ lines.push(`${ui.dim}…${ui.reset}`);
91
+ }
92
+ lines.push(`${ui.dim}Enter 提交 · Esc ${cameFromChoice ? '返回选项' : '取消'} · Ctrl+C 取消${ui.reset}`);
93
+ return lines;
94
+ }
95
+ function redraw() {
96
+ if (mode === 'choice') {
97
+ const hint = '↑↓ 选择 · Enter 确认 · 数字键直选 · Esc 取消';
98
+ layout.paintInput({
99
+ prompt: '❯ ',
100
+ lines: [hint],
101
+ cursorLine: 0,
102
+ cursorCol: displayWidth(hint),
103
+ menu: { lines: menuLinesChoice() },
104
+ caret: false, // 纯导航(非文本输入):不画输入框块状光标,聚焦由选项 ▸ 标记
105
+ });
106
+ }
107
+ else {
108
+ layout.paintInput({
109
+ prompt: '❯ ',
110
+ lines: [text],
111
+ cursorLine: 0,
112
+ cursorCol: displayWidth(text.slice(0, cursor)),
113
+ menu: { lines: menuLinesInput() },
114
+ });
115
+ }
116
+ }
117
+ function finish(result) {
118
+ if (resolved)
119
+ return;
120
+ resolved = true;
121
+ cleanup();
122
+ resolve(result);
123
+ }
124
+ /** 退出:摘自己的监听 + 恢复快照监听 + 擦菜单恢复内容区。不 setRawMode(false)/pause stdin(运行态由 repl 接管)。 */
125
+ function cleanup() {
126
+ emitter.removeListener('keypress', onKey);
127
+ for (const l of savedListeners)
128
+ emitter.on('keypress', l);
129
+ savedListeners = [];
130
+ // 先 paintInput(dim,menu:null):擦菜单(用 lastMenuRows)+ 画 dim 占位底栏 + 复位 lastMenuRows=0;
131
+ // 再 repaintViewport:从 content 缓冲重画内容区(恢复被菜单覆盖的 ● ask_human 行)。
132
+ // 顺序不可反——repaintViewport 后再 paintInput(menu:null) 会用 stale lastMenuRows 擦掉已恢复的内容。
133
+ layout.paintInput({
134
+ prompt: '❯ ',
135
+ lines: [''],
136
+ cursorLine: 0,
137
+ cursorCol: 0,
138
+ menu: null,
139
+ dim: true,
140
+ });
141
+ layout.repaintViewport();
142
+ }
143
+ function onKey(_str, key) {
144
+ if (resolved || !key)
145
+ return;
146
+ // Ctrl+C → 取消(不 reject SIGINT——否则经 executeTool 的 try/catch 变成 tool 错误串)
147
+ if (key.ctrl && key.name === 'c') {
148
+ finish({ action: 'cancelled' });
149
+ return;
150
+ }
151
+ if (key.name === 'escape') {
152
+ if (mode === 'input' && cameFromChoice) {
153
+ // 由 choice"自定义"项切入的 input:Esc 返回选项(不取消整次提问)
154
+ mode = 'choice';
155
+ redraw();
156
+ return;
157
+ }
158
+ finish({ action: 'cancelled' });
159
+ return;
160
+ }
161
+ if (mode === 'choice') {
162
+ onKeyChoice(key);
163
+ }
164
+ else {
165
+ onKeyInput(key);
166
+ }
167
+ }
168
+ function onKeyChoice(key) {
169
+ const itemCount = options.length + 1; // +自定义项
170
+ switch (key.name) {
171
+ case 'up':
172
+ selected = (selected - 1 + itemCount) % itemCount;
173
+ redraw();
174
+ return;
175
+ case 'down':
176
+ selected = (selected + 1) % itemCount;
177
+ redraw();
178
+ return;
179
+ case 'return':
180
+ case 'enter':
181
+ if (selected === options.length) {
182
+ // 自定义项 → 切 input 子态(空文本起)
183
+ mode = 'input';
184
+ cameFromChoice = true;
185
+ text = '';
186
+ cursor = 0;
187
+ redraw();
188
+ }
189
+ else {
190
+ finish({ action: 'selected', value: options[selected] });
191
+ }
192
+ return;
193
+ }
194
+ // 数字键 1-9 直选对应选项(自定义项不绑定数字)
195
+ const s = key.sequence ?? '';
196
+ if (s >= '1' && s <= '9') {
197
+ const n = Number(s) - 1;
198
+ if (n < options.length) {
199
+ finish({ action: 'selected', value: options[n] });
200
+ }
201
+ }
202
+ }
203
+ function onKeyInput(key) {
204
+ if (key.name === 'return' || key.name === 'enter') {
205
+ finish({ action: 'submitted', value: text });
206
+ return;
207
+ }
208
+ if (key.ctrl && key.name === 'a') {
209
+ cursor = 0;
210
+ redraw();
211
+ return;
212
+ }
213
+ if (key.ctrl && key.name === 'e') {
214
+ cursor = text.length;
215
+ redraw();
216
+ return;
217
+ }
218
+ switch (key.name) {
219
+ case 'backspace':
220
+ if (cursor > 0) {
221
+ text = text.slice(0, cursor - 1) + text.slice(cursor);
222
+ cursor--;
223
+ redraw();
224
+ }
225
+ return;
226
+ case 'left':
227
+ if (cursor > 0) {
228
+ cursor--;
229
+ redraw();
230
+ }
231
+ return;
232
+ case 'right':
233
+ if (cursor < text.length) {
234
+ cursor++;
235
+ redraw();
236
+ }
237
+ return;
238
+ case 'home':
239
+ cursor = 0;
240
+ redraw();
241
+ return;
242
+ case 'end':
243
+ cursor = text.length;
244
+ redraw();
245
+ return;
246
+ }
247
+ // 可打印字符(>= 空格,非 ctrl/meta)→ 插入光标处(\n < ' ' 自动排除,保单行)
248
+ const s = key.sequence ?? '';
249
+ if (s && s >= ' ' && !key.ctrl && !key.meta) {
250
+ text = text.slice(0, cursor) + s + text.slice(cursor);
251
+ cursor += s.length;
252
+ redraw();
253
+ }
254
+ }
255
+ return new Promise((res, rej) => {
256
+ resolve = res;
257
+ try {
258
+ // 进入面板:停 spinner(避免 onFrame 覆盖)+ 回尾(若用户正滚动回看)
259
+ Spinner.pauseCurrent();
260
+ layout.resetScroll();
261
+ // 快照现有 keypress 监听(运行态的 onRunningKey)并摘掉,挂自己的 onKey
262
+ savedListeners = emitter.listeners('keypress').slice();
263
+ for (const l of savedListeners)
264
+ emitter.removeListener('keypress', l);
265
+ readline.emitKeypressEvents(stdin); // 幂等(运行态已挂解析器,防御性再调)
266
+ // raw 模式:runAgent 期间 repl 已设 true;防御性确保(失败则按键不来,但不崩)
267
+ try {
268
+ stdin.setRawMode(true);
269
+ }
270
+ catch {
271
+ // 非 TTY / 不支持:忽略(实际非 TTY 已在上方 isActive 守卫返回)
272
+ }
273
+ stdin.resume();
274
+ emitter.on('keypress', onKey);
275
+ redraw();
276
+ }
277
+ catch (e) {
278
+ // 进入失败:必须恢复运行态监听,否则 onRunningKey 残留摘除 → 本 turns 的 Ctrl+C/滚动/typeahead 全废
279
+ try {
280
+ emitter.removeListener('keypress', onKey);
281
+ }
282
+ catch {
283
+ // 忽略
284
+ }
285
+ for (const l of savedListeners) {
286
+ try {
287
+ emitter.on('keypress', l);
288
+ }
289
+ catch {
290
+ // 忽略
291
+ }
292
+ }
293
+ savedListeners = [];
294
+ rej(e instanceof Error ? e : new Error(String(e)));
295
+ }
296
+ });
297
+ }
package/dist/ui/layout.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { stdin, stdout } from 'node:process';
2
- import { charWidth, displayWidth, truncateDisplay, ansiDisplayWidth, wrapByDisplayWidth, } from './render.js';
2
+ import { charWidth, displayWidth, truncateDisplay, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } from './render.js';
3
3
  import { ui } from './theme.js';
4
4
  import * as content from './content.js';
5
5
  // ── 内部状态 ──
6
6
  let active = false;
7
7
  let mode = 'input';
8
- let footerH = 2; // 1 状态行 + 输入行数
8
+ let footerH = 4; // 1 状态行 + 1 上线 + 输入行数 + 1 下线(上下线框住输入区)
9
9
  let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
10
10
  let contentCol = 1; // 续写位列(1-based)
11
11
  let segmentStartRow = 1; // 当前思考段起始屏行(供 eraseSegmentBack 定位擦除起点;段内行数由 content 段标记跟踪)
@@ -13,6 +13,8 @@ let scrollOffset = 0; // 滚动回看距尾行数(0=尾,跟随新内容);>0 时
13
13
  let base = null;
14
14
  let statusText = '';
15
15
  let spinnerFrame;
16
+ let turnStart = null; // RUNNING 态起点(Date.now());INPUT 态为 null。composeStatus 据此拼走时。
17
+ let turnTimer = null; // 走时刷新计时器(独立于 spinner):流式期间 spinner 停转,由它续刷状态行。
16
18
  let lastView = null;
17
19
  let lastMenuStartRow = 0; // 上次菜单起始屏行(供擦除)
18
20
  let lastMenuRows = 0;
@@ -282,9 +284,17 @@ function composeStatus(status, cols) {
282
284
  const ctxW = ansiDisplayWidth(ctx);
283
285
  // 滚动回看时状态段改显历史指示(无 spinner——滚动只在 INPUT 态)
284
286
  const scrolled = scrollOffset > 0;
285
- const st = scrolled
286
- ? `历史 ↑${scrollOffset} (PgDn 回底)`
287
- : (status.spinnerFrame ? status.spinnerFrame + ' ' : '') + status.status;
287
+ let st;
288
+ if (scrolled) {
289
+ st = `历史 ↑${scrollOffset} (PgDn 回底)`; // 滚动回看显历史指示,不显走时
290
+ }
291
+ else {
292
+ st = (status.spinnerFrame ? status.spinnerFrame + ' ' : '') + status.status;
293
+ // RUNNING 态追加走时:整轮从 enterRunningMode 起计时,200ms 计时器续刷使其连续递增。
294
+ if (mode === 'running' && turnStart != null) {
295
+ st += ` · ${fmtElapsed(Date.now() - turnStart)}`;
296
+ }
297
+ }
288
298
  const stW = displayWidth(st);
289
299
  const fixed = displayWidth(lead) + displayWidth(model) + sepW * 3 + ctxW + stW;
290
300
  const cwdBudget = cols - fixed - 1;
@@ -316,6 +326,26 @@ export function setStatus(status, frame) {
316
326
  spinnerFrame = frame;
317
327
  drawStatusBar();
318
328
  }
329
+ /**
330
+ * 启走时刷新计时器:RUNNING 态每 200ms 重画状态行,使 composeStatus 重算 elapsed。
331
+ * 必要性:spinner 在首 token 到达即 stop,思考/正文流式期间状态行不再经 spinner 刷新;
332
+ * 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 重叠幂等无妨。
333
+ * 非 TTY 不启(active=false 时 drawStatusBar 为 no-op)。
334
+ */
335
+ function startTurnTimer() {
336
+ if (!active)
337
+ return;
338
+ stopTurnTimer();
339
+ turnTimer = setInterval(() => drawStatusBar(), 200);
340
+ turnTimer.unref();
341
+ }
342
+ /** 停走时计时器。enterInputMode / exitAltScreen 调。 */
343
+ function stopTurnTimer() {
344
+ if (turnTimer) {
345
+ clearInterval(turnTimer);
346
+ turnTimer = null;
347
+ }
348
+ }
319
349
  /**
320
350
  * 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
321
351
  * 仅 TTY + offset=0(实时尾)时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
@@ -389,6 +419,31 @@ function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
389
419
  startVis,
390
420
  };
391
421
  }
422
+ /**
423
+ * 把一行纯可见文本(无 ANSI / 零宽)在光标显示列处切成 before/cur/after:
424
+ * cur = 光标右侧那个字符(块状光标"压"在它上面);光标在行末(列 == 行宽)则 cur=''。
425
+ * 供 paintInput 画块状光标——反白 cur(行末反白一个空格),让用户看清"现在在哪输入"。
426
+ *
427
+ * 光标列恒落在字符边界:cursorCol = 显示宽度(slice 整字累加),不进字符内部,故按 acc===col 取字符即可。
428
+ * 宽字符(CJK=2)在行尾放不下时整字折下行,光标列仍在边界,acc 逐字累加 displayWidth 与终端光标一致。
429
+ */
430
+ function splitAtVisCol(line, col) {
431
+ let acc = 0;
432
+ let i = 0;
433
+ for (const ch of line) {
434
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
435
+ if (cw <= 0) {
436
+ i += ch.length;
437
+ continue; // 零宽(组合符等):不计列,跳过(输入文本一般无,稳妥)
438
+ }
439
+ if (acc === col) {
440
+ return { before: line.slice(0, i), cur: ch, after: line.slice(i + ch.length) };
441
+ }
442
+ acc += cw;
443
+ i += ch.length;
444
+ }
445
+ return { before: line, cur: '', after: '' }; // 光标在行末:无字符可反白
446
+ }
392
447
  /**
393
448
  * 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
394
449
  * prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
@@ -398,13 +453,14 @@ export function paintInput(view) {
398
453
  return;
399
454
  lastView = view;
400
455
  const preGeo = getGeo();
456
+ // 累积本帧所有 cup/clearLine/文本,末尾一次写出——避免「擦旧菜单」与「重画」分多次 write 时,
457
+ // 终端把中间空白渲染出来 → 菜单/面板切换时闪烁(↑↓ 导航每次 redraw 都全帧擦后重画)。
458
+ let buf = '';
401
459
  // 1. 擦旧菜单(用上次记录的起始行 + 行数,与本次几何无关——setRegion 不动屏幕内容)
402
460
  if (lastMenuRows > 0) {
403
- let p = '';
404
461
  for (let i = 0; i < lastMenuRows; i++) {
405
- p += cup(lastMenuStartRow + i, 1) + esc.clearLine;
462
+ buf += cup(lastMenuStartRow + i, 1) + esc.clearLine;
406
463
  }
407
- stdout.write(p);
408
464
  lastMenuRows = 0;
409
465
  }
410
466
  // 2. 算输入可视行(软折行)+ 必要时 setRegion。dim=运行态占位:单行不折行。
@@ -418,55 +474,77 @@ export function paintInput(view) {
418
474
  startVis: 0,
419
475
  }
420
476
  : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
421
- const needFooterH = 1 + vis.inputRows;
477
+ const needFooterH = 3 + vis.inputRows; // 1 状态 + 1 上线 + 输入行 + 1 下线
422
478
  let g = preGeo;
423
- if (needFooterH !== footerH)
479
+ if (needFooterH !== footerH) {
480
+ // setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
481
+ // lastMenuStartRow,须在 setRegion 改区域前落地),再 setRegion,之后 2b..6 重新累积。
482
+ // footerH 不变时(常见:单行输入 / 菜单切换 / ↑↓ 导航)整帧一次 write,终端原子应用,无中间空白→不闪烁。
483
+ if (buf) {
484
+ stdout.write(buf);
485
+ buf = '';
486
+ }
424
487
  g = setRegion(needFooterH);
488
+ }
425
489
  // 2b. 重画内容末行(底栏上一行)从缓冲:防 WT 边距漏影(状态行重复到该行),并保证该行显正确内容
426
490
  {
427
491
  const slice = content.sliceFromEnd(scrollOffset, g.contentBottom);
428
492
  const line = slice[g.contentBottom - 1] ?? '';
429
- stdout.write(cup(g.contentBottom, 1) + esc.clearLine + line);
493
+ buf += cup(g.contentBottom, 1) + esc.clearLine + line;
430
494
  }
431
495
  // 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
432
496
  const statusRow = g.contentBottom + 1;
433
497
  const status = { ...base, status: statusText, spinnerFrame };
434
- stdout.write(cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols));
435
- // 4. 输入行(g.contentBottom+2 .. rows)——按可视行画,首行带 prompt、其余缩进 promptW
436
- const firstInputRow = g.contentBottom + 2;
437
- const inputRowsAvail = g.footerH - 1;
498
+ buf += cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols);
499
+ // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
500
+ buf += cup(g.contentBottom + 2, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
501
+ // 4. 输入行(g.contentBottom+3 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
502
+ const firstInputRow = g.contentBottom + 3;
503
+ const inputRowsAvail = g.footerH - 3; // 去掉状态/上线/下线,留输入行
438
504
  const indent = ' '.repeat(promptW);
505
+ const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
439
506
  for (let i = 0; i < inputRowsAvail; i++) {
440
507
  const line = vis.visRows[i] ?? '';
441
508
  const r = firstInputRow + i;
442
509
  const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
443
- const text = view.dim
444
- ? `${ui.dim}${prefix}${line}${ui.reset}`
445
- : `${prefix}${line}`;
446
- stdout.write(cup(r, 1) + esc.clearLine + text);
510
+ let text;
511
+ if (view.dim) {
512
+ text = `${ui.dim}${prefix}${line}${ui.reset}`;
513
+ }
514
+ else if (showCaret && i === vis.visLine) {
515
+ // 块状光标:反白光标右侧字符(cur),行末(无字符)反白一个空格——示"现在在哪输入"
516
+ const { before, cur, after } = splitAtVisCol(line, vis.cursorVisCol);
517
+ text = `${prefix}${before}${ui.reverse}${cur || ' '}${ui.reset}${after}`;
518
+ }
519
+ else {
520
+ text = `${prefix}${line}`;
521
+ }
522
+ buf += cup(r, 1) + esc.clearLine + text;
447
523
  }
524
+ // 4b. 下线(输入框底):满屏宽细线 ─(cyan),固定屏底 rows
525
+ buf += cup(g.rows, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
448
526
  // 5. 向上菜单(画在内容区底,底栏正上方)
449
527
  if (view.menu && view.menu.lines.length > 0) {
450
528
  const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
451
529
  const menuStart = g.contentBottom - menuRows + 1;
452
- let p = '';
453
530
  for (let i = 0; i < menuRows; i++) {
454
- p += cup(menuStart + i, 1) + esc.clearLine + view.menu.lines[i];
531
+ buf += cup(menuStart + i, 1) + esc.clearLine + view.menu.lines[i];
455
532
  }
456
- stdout.write(p);
457
533
  lastMenuStartRow = menuStart;
458
534
  lastMenuRows = menuRows;
459
535
  }
460
536
  // 6. 光标
461
537
  if (view.dim) {
462
538
  // 滚动回看时归内容区底(dim=运行态占位,光标不入输入框,viewport 锁历史)
463
- stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
539
+ buf += cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1);
464
540
  }
465
541
  else {
466
542
  const r = firstInputRow + vis.visLine;
467
543
  const col = promptW + vis.cursorVisCol + 1;
468
- stdout.write(cup(r, col));
544
+ buf += cup(r, col);
469
545
  }
546
+ if (buf)
547
+ stdout.write(buf); // 整帧一次写出(footerH 不变时):终端原子应用,无中间空白→不闪烁
470
548
  }
471
549
  /**
472
550
  * 运行态 typeahead 回显:定向写输入行(底栏输入框),把 dim 占位换成已打字文本(无打字时仍显 placeholder)。
@@ -478,9 +556,10 @@ export function paintRunningInputEcho(text, placeholder) {
478
556
  if (!active || !base)
479
557
  return;
480
558
  const g = getGeo();
481
- const inputRow = g.contentBottom + 2; // 运行态 footerH 恒 2:状态行(contentBottom+1)+ 输入行(contentBottom+2)
559
+ const inputRow = g.contentBottom + 3; // 运行态 footerH 恒 4:状态(+1)+上线(+2)+输入行(+3);下线在 rows
482
560
  const shown = text.length > 0 ? text : placeholder;
483
- stdout.write(cup(inputRow, 1) + esc.clearLine + `${ui.dim}❯ ${shown}${ui.reset}`);
561
+ const trimmed = truncateDisplay(shown, g.cols - 2); // 截断防超长软折行写穿下线
562
+ stdout.write(cup(inputRow, 1) + esc.clearLine + `${ui.dim}❯ ${trimmed}${ui.reset}`);
484
563
  // 同步 lastView:dim 视图(lines=回显文本),使滚动/resize 的 repaint 不擦掉已打字
485
564
  lastView = {
486
565
  prompt: '❯ ',
@@ -507,8 +586,10 @@ export function enterInputMode(status = '空闲') {
507
586
  mode = 'input';
508
587
  statusText = status;
509
588
  spinnerFrame = undefined;
589
+ turnStart = null; // 停走时
590
+ stopTurnTimer();
510
591
  if (active && base) {
511
- setRegion(2);
592
+ setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
512
593
  paintInput({
513
594
  prompt: '❯ ',
514
595
  lines: [''],
@@ -518,14 +599,15 @@ export function enterInputMode(status = '空闲') {
518
599
  });
519
600
  }
520
601
  }
521
- /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 2。新轮回尾(确保新内容可见)。 */
602
+ /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 4(状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
522
603
  export function enterRunningMode(status, placeholder) {
523
604
  mode = 'running';
524
605
  statusText = status;
525
606
  spinnerFrame = undefined;
607
+ turnStart = Date.now(); // 起走时(整轮从发起到 enterInputMode 止)
526
608
  resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
527
609
  if (active && base) {
528
- setRegion(2);
610
+ setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
529
611
  paintInput({
530
612
  prompt: '❯ ',
531
613
  lines: [placeholder],
@@ -534,6 +616,7 @@ export function enterRunningMode(status, placeholder) {
534
616
  menu: null,
535
617
  dim: true,
536
618
  });
619
+ startTurnTimer(); // 续刷状态行走时(流式期间 spinner 停转,由它兜底)
537
620
  contentMode();
538
621
  }
539
622
  }
@@ -544,7 +627,7 @@ export function enterAltScreen() {
544
627
  active = true;
545
628
  stdout.write(esc.altOn);
546
629
  stdout.write(esc.altScrollOn); // alt 屏滚轮转发 ↑/↓(滚轮滚动靠此 + onKey/onRunningKey 的 ↑/↓ 滚动)
547
- setRegion(2);
630
+ setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线(底栏始终含上下线)
548
631
  contentRow = 1;
549
632
  contentCol = 1;
550
633
  segmentStartRow = 1;
@@ -581,6 +664,8 @@ export function exitAltScreen() {
581
664
  if (!active)
582
665
  return;
583
666
  active = false;
667
+ stopTurnTimer(); // 兜底清走时计时器(防异常退出泄漏)
668
+ turnStart = null;
584
669
  // raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
585
670
  try {
586
671
  stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)