mocode-ai 0.1.3 → 0.1.5
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.
- package/README.md +196 -161
- package/dist/agent/core.js +302 -0
- package/dist/agent/index.js +85 -202
- package/dist/agent/mode.js +46 -0
- package/dist/agent/spawn.js +123 -0
- package/dist/commands/config.js +8 -26
- package/dist/config/file.js +48 -0
- package/dist/config/index.js +76 -22
- package/dist/llm/index.js +11 -9
- package/dist/memory/discover.js +49 -0
- package/dist/memory/index.js +45 -0
- package/dist/memory/reflect.js +265 -0
- package/dist/memory/store.js +339 -0
- package/dist/repl/index.js +334 -87
- package/dist/session/compact.js +51 -3
- package/dist/skills/index.js +2 -2
- package/dist/tools/builtins/ask-human.js +50 -0
- package/dist/tools/builtins/codegraph.js +113 -0
- package/dist/tools/builtins/edit-file.js +3 -3
- package/dist/tools/builtins/glob.js +3 -2
- package/dist/tools/builtins/grep.js +4 -3
- package/dist/tools/builtins/index.js +19 -1
- package/dist/tools/builtins/memory-forget.js +32 -0
- package/dist/tools/builtins/memory-list.js +34 -0
- package/dist/tools/builtins/memory-save.js +51 -0
- package/dist/tools/builtins/memory-search.js +42 -0
- package/dist/tools/builtins/memory-update.js +41 -0
- package/dist/tools/builtins/read-file.js +5 -4
- package/dist/tools/builtins/run-command.js +40 -7
- package/dist/tools/builtins/switch-mode.js +46 -0
- package/dist/tools/builtins/task.js +62 -0
- package/dist/tools/builtins/use-skill.js +2 -2
- package/dist/tools/builtins/web-fetch.js +16 -3
- package/dist/tools/builtins/web-search.js +6 -6
- package/dist/tools/builtins/write-file.js +3 -3
- package/dist/tools/constants.js +29 -0
- package/dist/tools/registry.js +11 -4
- package/dist/ui/content.js +25 -17
- package/dist/ui/diff.js +28 -26
- package/dist/ui/intervention.js +318 -0
- package/dist/ui/layout.js +402 -103
- package/dist/ui/markdown.js +607 -0
- package/dist/ui/mouse.js +93 -0
- package/dist/ui/prompt.js +469 -27
- package/dist/ui/render.js +19 -1
- package/dist/ui/spinner.js +15 -0
- package/dist/ui/theme.js +147 -13
- package/package.json +2 -2
package/dist/ui/layout.js
CHANGED
|
@@ -1,30 +1,50 @@
|
|
|
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
|
+
import * as mouse from './mouse.js';
|
|
6
|
+
import { renderMarkdown } from './markdown.js';
|
|
5
7
|
// ── 内部状态 ──
|
|
6
8
|
let active = false;
|
|
7
9
|
let mode = 'input';
|
|
8
|
-
let footerH =
|
|
10
|
+
let footerH = 4; // 1 状态行 + 1 上线 + 输入行数 + 1 下线(上下线框住输入区)
|
|
9
11
|
let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
|
|
10
12
|
let contentCol = 1; // 续写位列(1-based)
|
|
11
|
-
let segmentStartRow = 1; //
|
|
13
|
+
let segmentStartRow = 1; // 当前 md 段起始屏行(供 contentWriteMd 定位段末续写位;段内行数由 content 段标记跟踪)
|
|
12
14
|
let scrollOffset = 0; // 滚动回看距尾行数(0=尾,跟随新内容);>0 时 viewport 显历史、状态行显滚动指示
|
|
13
15
|
let base = null;
|
|
14
16
|
let statusText = '';
|
|
15
17
|
let spinnerFrame;
|
|
18
|
+
let turnStart = null; // RUNNING 态起点(Date.now());INPUT 态为 null。composeStatus 据此拼走时。
|
|
19
|
+
let turnTimer = null; // 走时刷新计时器(独立于 spinner):流式期间 spinner 停转,由它续刷状态行。
|
|
20
|
+
// 运行态状态行 chip 心跳帧(♥/♡ 明灭)。turnTimer 每 tick 推进一帧,让状态行前导符在 agent
|
|
21
|
+
// 运行时跳动——agent 的 spinner 走内容区续写位(paintLiveAtCursor),不调 setStatus,
|
|
22
|
+
// 故状态行 chip 靠 turnTimer 独立驱动。INPUT 态 runningFrame=-1,composeStatus 退回静态 ◆。
|
|
23
|
+
const RUNNING_FRAMES = ['♥', '♡'];
|
|
24
|
+
let runningFrame = -1;
|
|
25
|
+
// 运行态用户打字时暂停流式物理写:流式每个 token 要 cup 到 contentRow 写入,IME 候选窗逐光标移动跟踪会跟过去;
|
|
26
|
+
// 用户打字期间只喂缓冲、不物理写,光标留输入框;停手 USER_ACTIVE_PAUSE_MS 后 flush 重画缓冲内容。
|
|
27
|
+
let userActiveUntil = 0; // 打字活跃截止时刻(Date.now()+PAUSE);0=未活跃
|
|
28
|
+
let flushTimer = null; // 用户停手后 flush 缓冲内容(repaintViewport)
|
|
29
|
+
const USER_ACTIVE_PAUSE_MS = 1500;
|
|
16
30
|
let lastView = null;
|
|
17
31
|
let lastMenuStartRow = 0; // 上次菜单起始屏行(供擦除)
|
|
18
32
|
let lastMenuRows = 0;
|
|
19
33
|
let resizeTimer = null;
|
|
20
34
|
let exitHandler = null;
|
|
21
35
|
let sigwinchHandler = null;
|
|
36
|
+
// markdown 流式段:agent onText 的 chunk 累积到 mdBuf,每 chunk 把整段经 renderMarkdown
|
|
37
|
+
// 渲成自洽行,replace 缓冲段(content.setLines)+ repaintViewport 重画。mdActive 期间任何
|
|
38
|
+
// 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
|
|
39
|
+
let mdActive = false;
|
|
40
|
+
let mdBuf = '';
|
|
22
41
|
const esc = {
|
|
23
42
|
altOn: '\x1B[?1049h',
|
|
24
43
|
altOff: '\x1B[?1049l',
|
|
25
|
-
|
|
26
|
-
|
|
44
|
+
mouseOn: '\x1B[?1000h\x1B[?1006h', // SGR 鼠标:按键事件追踪(1000)+SGR 编码(1006)→ \x1B[<btn;col;rowM,经 mouse.consumeMouse 重组后滚轮滚动。副作用:TUI 内鼠标选区复制失效,按 Shift 用鼠标可让终端放行做原生选区
|
|
45
|
+
mouseOff: '\x1B[?1006l\x1B[?1000l', // 关:反序(先关 SGR 编码再关追踪)
|
|
27
46
|
cursorShow: '\x1B[?25h',
|
|
47
|
+
cursorHide: '\x1B[?25l',
|
|
28
48
|
clearLine: '\x1B[2K',
|
|
29
49
|
home: '\x1B[H',
|
|
30
50
|
};
|
|
@@ -66,11 +86,55 @@ export function setRegion(fh) {
|
|
|
66
86
|
contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
|
|
67
87
|
return g;
|
|
68
88
|
}
|
|
89
|
+
/** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+3(resize 安全),列对齐 renderDimInputRow 的假光标(空→3,有字→❯+截断文本宽+1)。供 IME 锚定。 */
|
|
90
|
+
function runningCaretPos() {
|
|
91
|
+
const g = getGeo();
|
|
92
|
+
const text = lastView?.dim ? lastView.lines[0] ?? '' : '';
|
|
93
|
+
const contentW = Math.max(0, g.cols - 3); // ❯ =2 + 光标=1
|
|
94
|
+
const w = displayWidth(truncateDisplay(text, contentW));
|
|
95
|
+
return { row: g.contentBottom + 3, col: Math.min(g.cols, 2 + w + 1) };
|
|
96
|
+
}
|
|
69
97
|
export function contentMode() {
|
|
70
98
|
if (!active)
|
|
71
99
|
return;
|
|
72
|
-
|
|
73
|
-
|
|
100
|
+
const g = getGeo();
|
|
101
|
+
if (mode === 'running' && scrollOffset === 0) {
|
|
102
|
+
// 运行态:真光标归输入框光标位(供 IME 锚定,气泡不跟流式跑);假光标做可见指示
|
|
103
|
+
const p = runningCaretPos();
|
|
104
|
+
stdout.write(cup(p.row, p.col));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// INPUT 态 / 滚动回看:归续写位(滚动回看时归内容区底,viewport 锁历史)
|
|
108
|
+
stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** 运行态用户是否在打字(近期有按键)——是则暂停流式物理写(contentWrite/drawStatusBar/paintLiveAtCursor 跳过物理写),光标留输入框,IME 候选窗稳定不跟流式跑。 */
|
|
112
|
+
export function isStreamingPaused() {
|
|
113
|
+
return mode === 'running' && Date.now() < userActiveUntil;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 运行态用户打字时调(repl onRunningKey 每次按键):标记活跃 + 重置 flush 定时器。
|
|
117
|
+
* 活跃期间流式只喂缓冲不物理写(光标不入内容区);用户停手 USER_ACTIVE_PAUSE_MS 后 flush:
|
|
118
|
+
* repaintViewport 重画缓冲内容 + drawStatusBar 刷状态行 + 光标归输入框。
|
|
119
|
+
* 解决 IME 候选窗跟流式输出跑:流式写必须 cup 到 contentRow,IME 逐光标跟踪→气泡跟跑;打字时暂停写则光标不动。
|
|
120
|
+
*/
|
|
121
|
+
export function setUserActive() {
|
|
122
|
+
if (!active || mode !== 'running')
|
|
123
|
+
return;
|
|
124
|
+
userActiveUntil = Date.now() + USER_ACTIVE_PAUSE_MS;
|
|
125
|
+
if (flushTimer)
|
|
126
|
+
clearTimeout(flushTimer);
|
|
127
|
+
flushTimer = setTimeout(() => {
|
|
128
|
+
flushTimer = null;
|
|
129
|
+
userActiveUntil = 0;
|
|
130
|
+
if (active && mode === 'running' && scrollOffset === 0) {
|
|
131
|
+
repaintViewport(); // 重画内容区(显示活跃期间缓冲的流式内容)
|
|
132
|
+
drawStatusBar(); // 刷状态行(活跃期间冻住,现恢复)
|
|
133
|
+
const p = runningCaretPos();
|
|
134
|
+
stdout.write(cup(p.row, p.col));
|
|
135
|
+
}
|
|
136
|
+
}, USER_ACTIVE_PAUSE_MS);
|
|
137
|
+
flushTimer.unref();
|
|
74
138
|
}
|
|
75
139
|
/**
|
|
76
140
|
* 内容区正文写:CUP 到续写位 + 写出 + 按字符宽度/折行更新续写位 + 同步入 content 缓冲(供滚动回看)。
|
|
@@ -85,14 +149,22 @@ export function contentWrite(s) {
|
|
|
85
149
|
stdout.write(s);
|
|
86
150
|
return;
|
|
87
151
|
}
|
|
152
|
+
if (mdActive)
|
|
153
|
+
commitMd(); // 非 md 写接续:先收尾 md 段(清 segMark,否则 setLines 会截到旧段)
|
|
88
154
|
// 滚动回看时(scrollOffset>0)只喂缓冲 + 推进续写位,不物理写——否则新流式内容覆盖 viewport 历史行。
|
|
89
|
-
// 回尾(scrollOffset===0)
|
|
90
|
-
|
|
91
|
-
|
|
155
|
+
// 回尾(scrollOffset===0)才物理写;写起点 = 当前续写位(loop 会推进续写位,故先捕获)。
|
|
156
|
+
const startRow = contentRow;
|
|
157
|
+
const startCol = contentCol;
|
|
92
158
|
const g = getGeo();
|
|
93
159
|
const cols = g.cols;
|
|
94
160
|
const bottom = g.contentBottom;
|
|
95
161
|
const advanceRow = (r) => (r >= bottom ? bottom : r + 1); // 到底则滚动,续写位留底
|
|
162
|
+
// 滚动回看冻结(仿 ink store.pushBlock 的 offset+=newLines):scrollOffset>0 时新内容只入缓冲,
|
|
163
|
+
// 但缓冲增长后若 offset 不变,下次 scrollBy→repaintViewport 会取漂移窗口、把新内容画进历史视图
|
|
164
|
+
// (「工具消息跳到上面覆盖」bug;滚回底又正常,因 buffer 正确)。故入缓冲前后算 totalRows 差,
|
|
165
|
+
// offset += 差以冻住视图(窗口停原绝对行,无需 repaint;滚回底自显尾部新内容)。
|
|
166
|
+
const scrolled = scrollOffset > 0;
|
|
167
|
+
const totalBefore = scrolled ? content.totalRows() : 0;
|
|
96
168
|
let i = 0;
|
|
97
169
|
while (i < s.length) {
|
|
98
170
|
const rest = s.slice(i);
|
|
@@ -159,58 +231,117 @@ export function contentWrite(s) {
|
|
|
159
231
|
content.feedChar(ch);
|
|
160
232
|
i += ch.length;
|
|
161
233
|
}
|
|
234
|
+
// 跨调用 pending-wrap 提交:循环末若 contentCol>cols(末字符恰好落在最后一列,终端 pending-wrap),
|
|
235
|
+
// 必须在此提交换行——否则下一 chunk 的 cup(startRow, cols+1) 被终端 clamp 到 col=cols 且 CUP 清除
|
|
236
|
+
// pending,下一可打印字符会覆盖上一行末字符(「新输出插到上文中间」bug);paintLiveAtCursor 也会
|
|
237
|
+
// cup 到 cols+1 并 \x1B[2K 擦掉整行。提交后模拟与终端都停在 (下一行,1)、无 pending,等价 ink 的
|
|
238
|
+
// 「新输出永远在底」。代价:chunk 恰在末列结束且下一 chunk 以 \n 开头时多一个空行(cosmetic,远好于覆盖)。
|
|
239
|
+
// 模拟归一化在物理写 guard 之外(滚动态也要保 buffer 自洽);物理补的 \n 在 guard 之内(滚动态/暂停态不物理写)。
|
|
240
|
+
const pendingWrap = contentCol > cols;
|
|
241
|
+
if (pendingWrap) {
|
|
242
|
+
content.breakRow();
|
|
243
|
+
contentRow = advanceRow(contentRow);
|
|
244
|
+
contentCol = 1;
|
|
245
|
+
}
|
|
246
|
+
// 滚动回看冻结:新内容入缓冲后 offset += delta(contentWrite 只增故 ≥0;md 路径可负),钳到 [0, maxOff],
|
|
247
|
+
// viewport 窗口停原绝对行(不漂移到新工具消息)。无需 repaint——窗口未变(屏仍显原历史)。
|
|
248
|
+
if (scrolled) {
|
|
249
|
+
const delta = content.totalRows() - totalBefore;
|
|
250
|
+
if (delta !== 0) {
|
|
251
|
+
const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
|
|
252
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// 物理写(回尾 offset=0 且未暂停):cup 写起点 + s + (pending 时补 \n 提交换行)+ (运行态 cup 回输入框)。
|
|
256
|
+
// 用户打字中(isStreamingPaused)跳过物理写、只喂缓冲——光标不入内容区,IME 候选窗不跟流式跑;
|
|
257
|
+
// 停手后 setUserActive 的 flush 定时器 repaintViewport 重画缓冲内容。
|
|
258
|
+
if (scrollOffset === 0 && !isStreamingPaused()) {
|
|
259
|
+
let out = cup(startRow, startCol) + s;
|
|
260
|
+
if (pendingWrap)
|
|
261
|
+
out += '\n'; // 滚动区域底行触发 DECSTBM 上滚、中段 LF 下移到 (下一行,1)
|
|
262
|
+
if (mode === 'running') {
|
|
263
|
+
const p = runningCaretPos();
|
|
264
|
+
out += cup(p.row, p.col);
|
|
265
|
+
}
|
|
266
|
+
stdout.write(out);
|
|
267
|
+
}
|
|
162
268
|
}
|
|
163
|
-
/**
|
|
164
|
-
|
|
269
|
+
/** 进入 markdown 流式段:标记段起点(layout 续写位 + content.beginSegment),清 accumulator。 */
|
|
270
|
+
function beginMdSegment() {
|
|
165
271
|
segmentStartRow = contentRow;
|
|
166
272
|
content.beginSegment();
|
|
273
|
+
mdBuf = '';
|
|
274
|
+
mdActive = true;
|
|
275
|
+
}
|
|
276
|
+
/** 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。 */
|
|
277
|
+
function commitMd() {
|
|
278
|
+
if (!mdActive)
|
|
279
|
+
return;
|
|
280
|
+
mdActive = false;
|
|
281
|
+
mdBuf = '';
|
|
282
|
+
content.commitSegment();
|
|
167
283
|
}
|
|
168
284
|
/**
|
|
169
|
-
*
|
|
285
|
+
* markdown 正文写(替代 contentWrite 用于 agent onText):累积 chunk 到 mdBuf,每 chunk 把整段
|
|
286
|
+
* mdBuf 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines 截旧 + 写新),
|
|
287
|
+
* repaintViewport 重画(仅尾 offset=0 且未暂停)。流式安全:未闭合 fence 照常 emit 进行中代码块;
|
|
288
|
+
* renderMarkdown 内部 memo by text 使重复渲染命中缓存。非 TTY / 未激活:直接 stdout.write(与
|
|
289
|
+
* contentWrite 一致,管道流式可见)。
|
|
170
290
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* - 否则未滚动:从段起点 segmentStartRow 擦。
|
|
175
|
-
* 不越界到区域外(不擦底栏)。返回擦除的屏幕行数。
|
|
176
|
-
*
|
|
177
|
-
* **滚动回看态(scrollOffset>0)只删缓冲、不物理擦**:段在滚动态经 contentWrite 只喂缓冲、未物理写屏,
|
|
178
|
-
* 屏上无段可见,此时按屏行 \x1B[2K 物理擦会清掉用户正在看的历史视图(思考折叠把整屏擦白,而非在底部默默隐藏)。
|
|
179
|
-
* 故滚动态仅 content.eraseSegment() 删缓冲 + 复位续写位到段起点(使回尾后续写落在折叠标题位),不发任何
|
|
180
|
-
* cup/clearLine;回尾时 repaintViewport 从缓冲重画即显折叠标题。返回 0(未物理擦)。
|
|
291
|
+
* 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
|
|
292
|
+
* 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
|
|
293
|
+
* 用户打字中(isStreamingPaused)或滚动回看(scrollOffset>0)只更新缓冲,停手 flush / 回尾时显。
|
|
181
294
|
*/
|
|
182
|
-
export function
|
|
183
|
-
if (!active)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const segRows = content.eraseSegment(); // 删缓冲段,返回段物理行数
|
|
187
|
-
if (segRows <= 0)
|
|
188
|
-
return 0;
|
|
189
|
-
if (scrollOffset > 0) {
|
|
190
|
-
// 滚动回看态:段未物理写屏,不物理擦(否则清穿历史视图);仅复位续写位到段起点
|
|
191
|
-
contentRow = segmentStartRow;
|
|
192
|
-
contentCol = 1;
|
|
193
|
-
return 0;
|
|
295
|
+
export function contentWriteMd(s) {
|
|
296
|
+
if (!active || !ui.isTTY) {
|
|
297
|
+
stdout.write(s);
|
|
298
|
+
return;
|
|
194
299
|
}
|
|
300
|
+
if (!mdActive)
|
|
301
|
+
beginMdSegment();
|
|
302
|
+
mdBuf += s;
|
|
303
|
+
const g = getGeo();
|
|
304
|
+
// 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
|
|
305
|
+
// 下次 scrollBy→repaintViewport 取漂移窗口、把流式正文画进历史视图。故 setLines 前后算 totalRows 差,
|
|
306
|
+
// offset += delta(可负:setLines 重渲染可能缩行)冻住视图。md 段在尾,窗口在上方,冻结后不重叠。
|
|
307
|
+
const scrolled = scrollOffset > 0;
|
|
308
|
+
const totalBefore = scrolled ? content.totalRows() : 0;
|
|
309
|
+
const lines = renderMarkdown(mdBuf, g.cols);
|
|
310
|
+
content.setLines(lines);
|
|
311
|
+
const segRows = lines.length;
|
|
195
312
|
const available = g.contentBottom - segmentStartRow + 1;
|
|
196
|
-
|
|
197
|
-
if (start < g.contentTop)
|
|
198
|
-
start = g.contentTop;
|
|
199
|
-
let rows = (contentRow - start) + (contentCol > 1 ? 1 : 0);
|
|
200
|
-
const maxRows = g.contentBottom - start + 1;
|
|
201
|
-
if (rows > maxRows)
|
|
202
|
-
rows = maxRows;
|
|
203
|
-
if (rows <= 0)
|
|
204
|
-
return 0;
|
|
205
|
-
let p = '';
|
|
206
|
-
for (let i = 0; i < rows; i++) {
|
|
207
|
-
p += cup(start + i, 1) + esc.clearLine;
|
|
208
|
-
}
|
|
209
|
-
stdout.write(p);
|
|
210
|
-
contentRow = start;
|
|
313
|
+
contentRow = segRows >= available ? g.contentBottom : segmentStartRow + segRows;
|
|
211
314
|
contentCol = 1;
|
|
212
|
-
|
|
213
|
-
|
|
315
|
+
if (scrolled) {
|
|
316
|
+
const delta = content.totalRows() - totalBefore;
|
|
317
|
+
if (delta !== 0) {
|
|
318
|
+
const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
|
|
319
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (scrollOffset === 0 && !isStreamingPaused()) {
|
|
323
|
+
repaintViewport();
|
|
324
|
+
if (mode === 'running') {
|
|
325
|
+
const p = runningCaretPos();
|
|
326
|
+
stdout.write(cup(p.row, p.col));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 把一段完整(非流式)assistant 文本一次性渲染成 markdown 写入(供 renderHistory 回显
|
|
332
|
+
* /resume / /rollback 复显上下文):渲染一次 → join('\n') → 经 contentWrite 增量写
|
|
333
|
+
* (无段 erase / 无 repaintViewport,无闪烁)。与流式 contentWriteMd 的区别:不累积 chunk、
|
|
334
|
+
* 不走段替换——一次性把渲染结果当普通带色文本喂入(渲染行各自 ≤ cols、自洽带色,contentWrite
|
|
335
|
+
* 的 SGR/折行状态机原样接纳)。非 TTY:退化为 stdout.write 原文(同 contentWrite)。
|
|
336
|
+
*/
|
|
337
|
+
export function contentWriteMdOnce(s) {
|
|
338
|
+
if (!active || !ui.isTTY) {
|
|
339
|
+
stdout.write(s);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const g = getGeo();
|
|
343
|
+
const lines = renderMarkdown(s, g.cols);
|
|
344
|
+
contentWrite(lines.join('\n'));
|
|
214
345
|
}
|
|
215
346
|
/** 清空内容区(保留底栏):全屏清 + 重设区域 + 续写位归 (1,1) + 清缓冲 + 回尾。底栏由调用方随后重画。 */
|
|
216
347
|
export function clearContent() {
|
|
@@ -222,6 +353,8 @@ export function clearContent() {
|
|
|
222
353
|
contentCol = 1;
|
|
223
354
|
segmentStartRow = 1;
|
|
224
355
|
scrollOffset = 0;
|
|
356
|
+
mdActive = false;
|
|
357
|
+
mdBuf = '';
|
|
225
358
|
content.reset();
|
|
226
359
|
stdout.write(esc.home);
|
|
227
360
|
}
|
|
@@ -276,21 +409,43 @@ export function resetScroll() {
|
|
|
276
409
|
function composeStatus(status, cols) {
|
|
277
410
|
const sep = ' ';
|
|
278
411
|
const sepW = sep.length;
|
|
279
|
-
|
|
412
|
+
// 运行态(含滚动回看)用心跳帧(♥/♡)替换静态 ◆:agent 跑起来时状态行前导符始终跳动,
|
|
413
|
+
// 滑到上面看历史也能感知 agent 在跑。帧宽=1(与 ◆ 同),fixed 不变,布局不乱。INPUT 态仍显 ◆。
|
|
414
|
+
const spinning = mode === 'running' && runningFrame >= 0;
|
|
415
|
+
const lead = spinning ? `${RUNNING_FRAMES[runningFrame]} ` : `◆ `;
|
|
416
|
+
// 模式 chip:lead 之后、model 之前。auto 显 dim 'auto'(常态),plan 显亮黄 'plan'(切换时颜色+文字都变,明显)。
|
|
417
|
+
const modeChip = status.modeTag
|
|
418
|
+
? ` ${status.modeTag === 'plan' ? ui.yellow : ui.dim}${status.modeTag}${ui.reset}`
|
|
419
|
+
: '';
|
|
420
|
+
const modeChipW = status.modeTag ? 1 + displayWidth(status.modeTag) : 0; // 1 = 前导空格
|
|
280
421
|
const model = truncateDisplay(status.model, 22);
|
|
281
422
|
const ctx = status.contextBar; // 已带色
|
|
282
423
|
const ctxW = ansiDisplayWidth(ctx);
|
|
283
424
|
// 滚动回看时状态段改显历史指示(无 spinner——滚动只在 INPUT 态)
|
|
284
425
|
const scrolled = scrollOffset > 0;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
426
|
+
let st;
|
|
427
|
+
if (scrolled) {
|
|
428
|
+
st = `历史 ↑${scrollOffset} (PgDn 回底)`; // 滚动回看显历史指示,不显走时
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
// head = spinner 帧 + 状态文字。常态 agent 轮二者皆空:agent spinner 不调 setStatus,
|
|
432
|
+
// 其文字在内联 paintLiveAtCursor 显,状态行不重复——只显走时。命令态(如 /compact 的「压缩中」)
|
|
433
|
+
// 走 setStatus,在此显帧 + 文字(无内联,不重复)。
|
|
434
|
+
const head = (status.spinnerFrame ? status.spinnerFrame + ' ' : '') + status.status;
|
|
435
|
+
if (mode === 'running' && turnStart != null) {
|
|
436
|
+
// RUNNING 态追加走时:整轮从 enterRunningMode 起计时,200ms 计时器续刷使其连续递增。
|
|
437
|
+
st = head ? `${head} · ${fmtElapsed(Date.now() - turnStart)}` : fmtElapsed(Date.now() - turnStart);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
st = head; // INPUT 等态(如"空闲")
|
|
441
|
+
}
|
|
442
|
+
}
|
|
288
443
|
const stW = displayWidth(st);
|
|
289
|
-
const fixed = displayWidth(lead) + displayWidth(model) + sepW * 3 + ctxW + stW;
|
|
444
|
+
const fixed = displayWidth(lead) + modeChipW + displayWidth(model) + sepW * 3 + ctxW + stW;
|
|
290
445
|
const cwdBudget = cols - fixed - 1;
|
|
291
446
|
const cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
|
|
292
447
|
return [
|
|
293
|
-
`${ui.brightCyan}${lead}${ui.reset}${ui.bold}${model}${ui.reset}`,
|
|
448
|
+
`${spinning ? ui.brightMagenta : ui.brightCyan}${lead}${ui.reset}${modeChip}${ui.bold}${model}${ui.reset}`,
|
|
294
449
|
sep,
|
|
295
450
|
ctx,
|
|
296
451
|
sep,
|
|
@@ -303,12 +458,21 @@ function composeStatus(status, cols) {
|
|
|
303
458
|
export function drawStatusBar(status) {
|
|
304
459
|
if (!active || !base)
|
|
305
460
|
return;
|
|
461
|
+
if (isStreamingPaused())
|
|
462
|
+
return; // 用户打字中:状态行暂不刷(避免光标去 statusRow 扰动 IME),停手后 flush 会刷
|
|
306
463
|
const s = status ?? { ...base, status: statusText, spinnerFrame };
|
|
307
464
|
const g = getGeo();
|
|
308
465
|
const statusRow = g.contentBottom + 1;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
466
|
+
// 一次写入:cup 状态行 + clearLine + 状态 + cup 回(运行态→输入框供 IME 锚定;否则续写位/内容区底)
|
|
467
|
+
let out = cup(statusRow, 1) + esc.clearLine + composeStatus(s, g.cols);
|
|
468
|
+
if (mode === 'running' && scrollOffset === 0) {
|
|
469
|
+
const p = runningCaretPos();
|
|
470
|
+
out += cup(p.row, p.col);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
out += cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1);
|
|
474
|
+
}
|
|
475
|
+
stdout.write(out);
|
|
312
476
|
}
|
|
313
477
|
/** 更新易变状态(状态文字 + spinner 帧)并重画状态行。spinner / agent 用。 */
|
|
314
478
|
export function setStatus(status, frame) {
|
|
@@ -316,23 +480,58 @@ export function setStatus(status, frame) {
|
|
|
316
480
|
spinnerFrame = frame;
|
|
317
481
|
drawStatusBar();
|
|
318
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* 启走时刷新计时器:RUNNING 态每 200ms 重画状态行,使 composeStatus 重算 elapsed。
|
|
485
|
+
* 必要性:spinner 在首 token 到达即 stop,思考/正文流式期间状态行不再经 spinner 刷新;
|
|
486
|
+
* 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 重叠幂等无妨。
|
|
487
|
+
* 非 TTY 不启(active=false 时 drawStatusBar 为 no-op)。
|
|
488
|
+
*/
|
|
489
|
+
function startTurnTimer() {
|
|
490
|
+
if (!active)
|
|
491
|
+
return;
|
|
492
|
+
stopTurnTimer();
|
|
493
|
+
runningFrame = 0; // 进入运行态:启动状态行 chip 心跳(首帧立即生效)
|
|
494
|
+
turnTimer = setInterval(() => {
|
|
495
|
+
runningFrame = (runningFrame + 1) % RUNNING_FRAMES.length; // 推进心跳帧,让前导符跳动
|
|
496
|
+
drawStatusBar();
|
|
497
|
+
}, 200);
|
|
498
|
+
turnTimer.unref();
|
|
499
|
+
}
|
|
500
|
+
/** 停走时计时器。enterInputMode / exitAltScreen 调。 */
|
|
501
|
+
function stopTurnTimer() {
|
|
502
|
+
if (turnTimer) {
|
|
503
|
+
clearInterval(turnTimer);
|
|
504
|
+
turnTimer = null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
319
507
|
/**
|
|
320
508
|
* 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
|
|
321
509
|
* 仅 TTY + offset=0(实时尾)时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
|
|
322
510
|
* 配合 clearLiveAtCursor 在 spinner 停时清掉,随后 contentWrite 的结果即写在该行(spinner 不入历史缓冲)。
|
|
323
511
|
*/
|
|
324
512
|
export function paintLiveAtCursor(text) {
|
|
325
|
-
if (!active || !ui.isTTY || scrollOffset !== 0)
|
|
513
|
+
if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused())
|
|
326
514
|
return;
|
|
327
|
-
|
|
515
|
+
// 一次写入:cup 续写位 + clearLine + 帧 + (运行态 cup 回输入框)——避免光标在续写位歇脚被 IME 跟踪
|
|
516
|
+
let out = cup(contentRow, contentCol) + esc.clearLine + text;
|
|
517
|
+
if (mode === 'running') {
|
|
518
|
+
const p = runningCaretPos();
|
|
519
|
+
out += cup(p.row, p.col);
|
|
520
|
+
}
|
|
521
|
+
stdout.write(out);
|
|
328
522
|
}
|
|
329
523
|
/** 清掉续写位那行瞬时活动文本(配合 paintLiveAtCursor)。 */
|
|
330
524
|
export function clearLiveAtCursor() {
|
|
331
|
-
if (!active || !ui.isTTY || scrollOffset !== 0)
|
|
525
|
+
if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused())
|
|
332
526
|
return;
|
|
333
|
-
|
|
527
|
+
let out = cup(contentRow, contentCol) + esc.clearLine;
|
|
528
|
+
if (mode === 'running') {
|
|
529
|
+
const p = runningCaretPos();
|
|
530
|
+
out += cup(p.row, p.col);
|
|
531
|
+
}
|
|
532
|
+
stdout.write(out);
|
|
334
533
|
}
|
|
335
|
-
/** 更新状态行基线(模型 / context / cwd)。repl
|
|
534
|
+
/** 更新状态行基线(模型 / context / cwd / 模式标识)。repl 在轮次边界与切模式时调。 */
|
|
336
535
|
export function setStatusBase(b) {
|
|
337
536
|
base = b;
|
|
338
537
|
}
|
|
@@ -389,6 +588,31 @@ function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
|
|
|
389
588
|
startVis,
|
|
390
589
|
};
|
|
391
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* 把一行纯可见文本(无 ANSI / 零宽)在光标显示列处切成 before/cur/after:
|
|
593
|
+
* cur = 光标右侧那个字符(块状光标"压"在它上面);光标在行末(列 == 行宽)则 cur=''。
|
|
594
|
+
* 供 paintInput 画块状光标——反白 cur(行末反白一个空格),让用户看清"现在在哪输入"。
|
|
595
|
+
*
|
|
596
|
+
* 光标列恒落在字符边界:cursorCol = 显示宽度(slice 整字累加),不进字符内部,故按 acc===col 取字符即可。
|
|
597
|
+
* 宽字符(CJK=2)在行尾放不下时整字折下行,光标列仍在边界,acc 逐字累加 displayWidth 与终端光标一致。
|
|
598
|
+
*/
|
|
599
|
+
function splitAtVisCol(line, col) {
|
|
600
|
+
let acc = 0;
|
|
601
|
+
let i = 0;
|
|
602
|
+
for (const ch of line) {
|
|
603
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
604
|
+
if (cw <= 0) {
|
|
605
|
+
i += ch.length;
|
|
606
|
+
continue; // 零宽(组合符等):不计列,跳过(输入文本一般无,稳妥)
|
|
607
|
+
}
|
|
608
|
+
if (acc === col) {
|
|
609
|
+
return { before: line.slice(0, i), cur: ch, after: line.slice(i + ch.length) };
|
|
610
|
+
}
|
|
611
|
+
acc += cw;
|
|
612
|
+
i += ch.length;
|
|
613
|
+
}
|
|
614
|
+
return { before: line, cur: '', after: '' }; // 光标在行末:无字符可反白
|
|
615
|
+
}
|
|
392
616
|
/**
|
|
393
617
|
* 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
|
|
394
618
|
* prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
|
|
@@ -398,13 +622,14 @@ export function paintInput(view) {
|
|
|
398
622
|
return;
|
|
399
623
|
lastView = view;
|
|
400
624
|
const preGeo = getGeo();
|
|
625
|
+
// 累积本帧所有 cup/clearLine/文本,末尾一次写出——避免「擦旧菜单」与「重画」分多次 write 时,
|
|
626
|
+
// 终端把中间空白渲染出来 → 菜单/面板切换时闪烁(↑↓ 导航每次 redraw 都全帧擦后重画)。
|
|
627
|
+
let buf = '';
|
|
401
628
|
// 1. 擦旧菜单(用上次记录的起始行 + 行数,与本次几何无关——setRegion 不动屏幕内容)
|
|
402
629
|
if (lastMenuRows > 0) {
|
|
403
|
-
let p = '';
|
|
404
630
|
for (let i = 0; i < lastMenuRows; i++) {
|
|
405
|
-
|
|
631
|
+
buf += cup(lastMenuStartRow + i, 1) + esc.clearLine;
|
|
406
632
|
}
|
|
407
|
-
stdout.write(p);
|
|
408
633
|
lastMenuRows = 0;
|
|
409
634
|
}
|
|
410
635
|
// 2. 算输入可视行(软折行)+ 必要时 setRegion。dim=运行态占位:单行不折行。
|
|
@@ -418,80 +643,124 @@ export function paintInput(view) {
|
|
|
418
643
|
startVis: 0,
|
|
419
644
|
}
|
|
420
645
|
: windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
|
|
421
|
-
const needFooterH =
|
|
646
|
+
const needFooterH = 3 + vis.inputRows; // 1 状态 + 1 上线 + 输入行 + 1 下线
|
|
422
647
|
let g = preGeo;
|
|
423
|
-
if (needFooterH !== footerH)
|
|
648
|
+
if (needFooterH !== footerH) {
|
|
649
|
+
// setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
|
|
650
|
+
// lastMenuStartRow,须在 setRegion 改区域前落地),再 setRegion,之后 2b..6 重新累积。
|
|
651
|
+
// footerH 不变时(常见:单行输入 / 菜单切换 / ↑↓ 导航)整帧一次 write,终端原子应用,无中间空白→不闪烁。
|
|
652
|
+
if (buf) {
|
|
653
|
+
stdout.write(buf);
|
|
654
|
+
buf = '';
|
|
655
|
+
}
|
|
424
656
|
g = setRegion(needFooterH);
|
|
657
|
+
}
|
|
425
658
|
// 2b. 重画内容末行(底栏上一行)从缓冲:防 WT 边距漏影(状态行重复到该行),并保证该行显正确内容
|
|
426
659
|
{
|
|
427
660
|
const slice = content.sliceFromEnd(scrollOffset, g.contentBottom);
|
|
428
661
|
const line = slice[g.contentBottom - 1] ?? '';
|
|
429
|
-
|
|
662
|
+
buf += cup(g.contentBottom, 1) + esc.clearLine + line;
|
|
430
663
|
}
|
|
431
664
|
// 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
|
|
432
665
|
const statusRow = g.contentBottom + 1;
|
|
433
666
|
const status = { ...base, status: statusText, spinnerFrame };
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
667
|
+
buf += cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols);
|
|
668
|
+
// 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
|
|
669
|
+
buf += cup(g.contentBottom + 2, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
670
|
+
// 4. 输入行(g.contentBottom+3 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
|
|
671
|
+
const firstInputRow = g.contentBottom + 3;
|
|
672
|
+
const inputRowsAvail = g.footerH - 3; // 去掉状态/上线/下线,留输入行
|
|
438
673
|
const indent = ' '.repeat(promptW);
|
|
674
|
+
const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
|
|
439
675
|
for (let i = 0; i < inputRowsAvail; i++) {
|
|
440
676
|
const line = vis.visRows[i] ?? '';
|
|
441
677
|
const r = firstInputRow + i;
|
|
442
678
|
const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
679
|
+
let text;
|
|
680
|
+
if (view.dim) {
|
|
681
|
+
text = renderDimInputRow(view.prompt, line, view.placeholder ?? '', g.cols);
|
|
682
|
+
}
|
|
683
|
+
else if (showCaret && i === vis.visLine) {
|
|
684
|
+
// 块状光标:反白光标右侧字符(cur),行末(无字符)反白一个空格——示"现在在哪输入"
|
|
685
|
+
const { before, cur, after } = splitAtVisCol(line, vis.cursorVisCol);
|
|
686
|
+
text = `${prefix}${before}${ui.reverse}${cur || ' '}${ui.reset}${after}`;
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
text = `${prefix}${line}`;
|
|
690
|
+
}
|
|
691
|
+
buf += cup(r, 1) + esc.clearLine + text;
|
|
447
692
|
}
|
|
693
|
+
// 4b. 下线(输入框底):满屏宽细线 ─(cyan),固定屏底 rows
|
|
694
|
+
buf += cup(g.rows, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
448
695
|
// 5. 向上菜单(画在内容区底,底栏正上方)
|
|
449
696
|
if (view.menu && view.menu.lines.length > 0) {
|
|
450
697
|
const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
|
|
451
698
|
const menuStart = g.contentBottom - menuRows + 1;
|
|
452
|
-
let p = '';
|
|
453
699
|
for (let i = 0; i < menuRows; i++) {
|
|
454
|
-
|
|
700
|
+
buf += cup(menuStart + i, 1) + esc.clearLine + view.menu.lines[i];
|
|
455
701
|
}
|
|
456
|
-
stdout.write(p);
|
|
457
702
|
lastMenuStartRow = menuStart;
|
|
458
703
|
lastMenuRows = menuRows;
|
|
459
704
|
}
|
|
460
705
|
// 6. 光标
|
|
461
706
|
if (view.dim) {
|
|
462
|
-
//
|
|
463
|
-
|
|
707
|
+
// 运行态:真光标归输入框光标位(供 IME 锚定);滚动回看时归内容区底(viewport 锁历史)
|
|
708
|
+
const p = runningCaretPos();
|
|
709
|
+
buf += cup(scrollOffset === 0 ? p.row : g.contentBottom, scrollOffset === 0 ? p.col : 1);
|
|
464
710
|
}
|
|
465
711
|
else {
|
|
466
712
|
const r = firstInputRow + vis.visLine;
|
|
467
713
|
const col = promptW + vis.cursorVisCol + 1;
|
|
468
|
-
|
|
714
|
+
buf += cup(r, col);
|
|
715
|
+
}
|
|
716
|
+
if (buf)
|
|
717
|
+
stdout.write(buf); // 整帧一次写出(footerH 不变时):终端原子应用,无中间空白→不闪烁
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* 渲染运行态输入行(dim):❯ + (有打字:dim 文本 + 末尾反白块状光标;无打字:反白光标在起点 + dim 占位 ghost)。
|
|
721
|
+
* 光标是「画出来」的反白块(非真终端光标)——真光标仍归续写位,避开与 contentWrite/drawStatusBar 争用;
|
|
722
|
+
* 输入行在 RUNNING 期间只被按键回显重画(流式/spinner 不碰这行),故画上去的光标能稳住。
|
|
723
|
+
* 内容截断到 cols-promptW-1(留 ❯ 与光标各 1 cell),防超长软折行写穿下线。
|
|
724
|
+
*/
|
|
725
|
+
function renderDimInputRow(prompt, text, placeholder, cols) {
|
|
726
|
+
const promptW = displayWidth(prompt);
|
|
727
|
+
const caret = `${ui.reverse} ${ui.reset}`; // 反白块状光标(1 cell,与 INPUT 态同款)
|
|
728
|
+
const contentW = Math.max(0, cols - promptW - 1);
|
|
729
|
+
if (text.length > 0) {
|
|
730
|
+
// 有打字:❯ dim + 文本(dim) + 反白光标(末尾)
|
|
731
|
+
return `${ui.dim}${prompt}${truncateDisplay(text, contentW)}${ui.reset}${caret}`;
|
|
469
732
|
}
|
|
733
|
+
// 空:❯ dim + 反白光标(打字起点) + dim 占位 ghost
|
|
734
|
+
const p = placeholder ? truncateDisplay(placeholder, contentW) : '';
|
|
735
|
+
return `${ui.dim}${prompt}${ui.reset}${caret}${ui.dim}${p}${ui.reset}`;
|
|
470
736
|
}
|
|
471
737
|
/**
|
|
472
|
-
* 运行态 typeahead 回显:定向写输入行(底栏输入框),把 dim 占位换成已打字文本(
|
|
738
|
+
* 运行态 typeahead 回显:定向写输入行(底栏输入框),把 dim 占位换成已打字文本 + 反白块状光标(无打字时光标在起点)。
|
|
473
739
|
* 只 cup 输入行 + clearLine + dim 文本 + 归位——不调 setRegion(运行中禁多行,避免 DECSTBM 抖动)、
|
|
474
|
-
* 不重画状态行/contentBottom、不用 ED。同步 lastView 为 dim
|
|
475
|
-
*
|
|
740
|
+
* 不重画状态行/contentBottom、不用 ED。同步 lastView 为 dim 视图(text 与 placeholder 拆开存),使 scrollBy/resize
|
|
741
|
+
* 的 repaint 仍显当前回显 + 光标。真光标归续写位(滚动回看时归内容区底)——不入输入框,假光标已画在行内。
|
|
476
742
|
*/
|
|
477
743
|
export function paintRunningInputEcho(text, placeholder) {
|
|
478
744
|
if (!active || !base)
|
|
479
745
|
return;
|
|
480
746
|
const g = getGeo();
|
|
481
|
-
const inputRow = g.contentBottom +
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
747
|
+
const inputRow = g.contentBottom + 3; // 运行态 footerH 恒 4:状态(+1)+上线(+2)+输入行(+3);下线在 rows
|
|
748
|
+
stdout.write(cup(inputRow, 1) +
|
|
749
|
+
esc.clearLine +
|
|
750
|
+
renderDimInputRow('❯ ', text, placeholder, g.cols));
|
|
751
|
+
// 同步 lastView:text 与 placeholder 拆开存,使滚动/resize 的 repaint 重画时光标位置正确(空→起点,有字→末尾)
|
|
485
752
|
lastView = {
|
|
486
753
|
prompt: '❯ ',
|
|
487
|
-
lines: [
|
|
754
|
+
lines: [text],
|
|
755
|
+
placeholder,
|
|
488
756
|
cursorLine: 0,
|
|
489
757
|
cursorCol: 0,
|
|
490
758
|
menu: null,
|
|
491
759
|
dim: true,
|
|
492
760
|
};
|
|
493
|
-
//
|
|
494
|
-
|
|
761
|
+
// 真光标归输入框光标位(供 IME 锚定,与假光标同位);滚动回看时归内容区底
|
|
762
|
+
const p = runningCaretPos();
|
|
763
|
+
stdout.write(cup(scrollOffset === 0 ? p.row : g.contentBottom, scrollOffset === 0 ? p.col : 1));
|
|
495
764
|
}
|
|
496
765
|
/** 重画当前视图(resize / 内部用)。 */
|
|
497
766
|
export function repaint() {
|
|
@@ -507,8 +776,21 @@ export function enterInputMode(status = '空闲') {
|
|
|
507
776
|
mode = 'input';
|
|
508
777
|
statusText = status;
|
|
509
778
|
spinnerFrame = undefined;
|
|
779
|
+
runningFrame = -1; // 回 INPUT 态:停状态行 chip 旋转,composeStatus 退回静态 ◆
|
|
780
|
+
turnStart = null; // 停走时
|
|
781
|
+
stopTurnTimer();
|
|
782
|
+
// 运行态若有未 flush 的缓冲内容(用户打字暂停了流式写),切回 INPUT 前重画内容区显示之,免丢内容
|
|
783
|
+
if (flushTimer) {
|
|
784
|
+
clearTimeout(flushTimer);
|
|
785
|
+
flushTimer = null;
|
|
786
|
+
}
|
|
787
|
+
if (userActiveUntil) {
|
|
788
|
+
userActiveUntil = 0;
|
|
789
|
+
if (active)
|
|
790
|
+
repaintViewport();
|
|
791
|
+
}
|
|
510
792
|
if (active && base) {
|
|
511
|
-
setRegion(
|
|
793
|
+
setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
|
|
512
794
|
paintInput({
|
|
513
795
|
prompt: '❯ ',
|
|
514
796
|
lines: [''],
|
|
@@ -516,25 +798,32 @@ export function enterInputMode(status = '空闲') {
|
|
|
516
798
|
cursorCol: 0,
|
|
517
799
|
menu: null,
|
|
518
800
|
});
|
|
801
|
+
stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
|
|
519
802
|
}
|
|
520
803
|
}
|
|
521
|
-
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒
|
|
804
|
+
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 4(状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
|
|
522
805
|
export function enterRunningMode(status, placeholder) {
|
|
523
806
|
mode = 'running';
|
|
524
807
|
statusText = status;
|
|
525
808
|
spinnerFrame = undefined;
|
|
809
|
+
turnStart = Date.now(); // 起走时(整轮从发起到 enterInputMode 止)
|
|
526
810
|
resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
|
|
527
811
|
if (active && base) {
|
|
528
|
-
setRegion(
|
|
812
|
+
setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
|
|
529
813
|
paintInput({
|
|
530
814
|
prompt: '❯ ',
|
|
531
|
-
lines: [
|
|
815
|
+
lines: [''],
|
|
816
|
+
placeholder,
|
|
532
817
|
cursorLine: 0,
|
|
533
818
|
cursorCol: 0,
|
|
534
819
|
menu: null,
|
|
535
820
|
dim: true,
|
|
536
821
|
});
|
|
822
|
+
startTurnTimer(); // 续刷状态行走时(流式期间 spinner 停转,由它兜底)
|
|
537
823
|
contentMode();
|
|
824
|
+
// 真光标可见,停在输入框光标位(供 IME 锚定气泡到光标处)。打字时 setUserActive 暂停流式写、
|
|
825
|
+
// 光标稳定在输入框;流式写时瞬时到 contentRow 但合并写入很快回输入框(光标跟流式是终端常态)。
|
|
826
|
+
// 不再藏光标:藏了 IME 气泡会跑到输入框最右边而非光标处(WT 不锚定隐藏光标)。
|
|
538
827
|
}
|
|
539
828
|
}
|
|
540
829
|
// ── alt screen 生命周期 + 钩子 ──
|
|
@@ -543,12 +832,14 @@ export function enterAltScreen() {
|
|
|
543
832
|
return;
|
|
544
833
|
active = true;
|
|
545
834
|
stdout.write(esc.altOn);
|
|
546
|
-
stdout.write(esc.
|
|
547
|
-
setRegion(
|
|
835
|
+
stdout.write(esc.mouseOn); // SGR 鼠标追踪:滚轮发 \x1B[<btn;col;rowM 报表,经 mouse.consumeMouse 重组 → scrollBy(滚轮滚动靠此 + onKey/onRunningKey 顶部守卫)
|
|
836
|
+
setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线(底栏始终含上下线)
|
|
548
837
|
contentRow = 1;
|
|
549
838
|
contentCol = 1;
|
|
550
839
|
segmentStartRow = 1;
|
|
551
840
|
scrollOffset = 0;
|
|
841
|
+
mdActive = false;
|
|
842
|
+
mdBuf = '';
|
|
552
843
|
content.reset();
|
|
553
844
|
exitHandler = () => exitAltScreen();
|
|
554
845
|
process.on('exit', exitHandler);
|
|
@@ -581,6 +872,9 @@ export function exitAltScreen() {
|
|
|
581
872
|
if (!active)
|
|
582
873
|
return;
|
|
583
874
|
active = false;
|
|
875
|
+
stopTurnTimer(); // 兜底清走时计时器(防异常退出泄漏)
|
|
876
|
+
turnStart = null;
|
|
877
|
+
mouse.resetMouse(); // 清鼠标重组残留(防退出后状态泄漏到下次进 alt 屏)
|
|
584
878
|
// raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
|
|
585
879
|
try {
|
|
586
880
|
stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
|
|
@@ -591,7 +885,7 @@ export function exitAltScreen() {
|
|
|
591
885
|
try {
|
|
592
886
|
stdout.write('\x1B[r'); // 复位 DECSTBM margins
|
|
593
887
|
stdout.write(esc.cursorShow);
|
|
594
|
-
stdout.write(esc.
|
|
888
|
+
stdout.write(esc.mouseOff); // 关 SGR 鼠标追踪(反序:先 1006l 再 1000l)
|
|
595
889
|
stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
|
|
596
890
|
}
|
|
597
891
|
catch {
|
|
@@ -619,6 +913,11 @@ export function exitAltScreen() {
|
|
|
619
913
|
clearTimeout(resizeTimer);
|
|
620
914
|
resizeTimer = null;
|
|
621
915
|
}
|
|
916
|
+
if (flushTimer) {
|
|
917
|
+
clearTimeout(flushTimer);
|
|
918
|
+
flushTimer = null;
|
|
919
|
+
}
|
|
920
|
+
userActiveUntil = 0;
|
|
622
921
|
}
|
|
623
922
|
export function isActive() {
|
|
624
923
|
return active;
|