mocode-ai 0.1.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.
@@ -0,0 +1,609 @@
1
+ import { stdin, stdout } from 'node:process';
2
+ import { charWidth, displayWidth, truncateDisplay, ansiDisplayWidth, wrapByDisplayWidth, } from './render.js';
3
+ import { ui } from './theme.js';
4
+ import * as content from './content.js';
5
+ // ── 内部状态 ──
6
+ let active = false;
7
+ let mode = 'input';
8
+ let footerH = 2; // 1 状态行 + 输入行数
9
+ let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
10
+ let contentCol = 1; // 续写位列(1-based)
11
+ let segmentStartRow = 1; // 当前思考段起始屏行(供 eraseSegmentBack 定位擦除起点;段内行数由 content 段标记跟踪)
12
+ let scrollOffset = 0; // 滚动回看距尾行数(0=尾,跟随新内容);>0 时 viewport 显历史、状态行显滚动指示
13
+ let base = null;
14
+ let statusText = '';
15
+ let spinnerFrame;
16
+ let lastView = null;
17
+ let lastMenuStartRow = 0; // 上次菜单起始屏行(供擦除)
18
+ let lastMenuRows = 0;
19
+ let resizeTimer = null;
20
+ let exitHandler = null;
21
+ let sigwinchHandler = null;
22
+ const esc = {
23
+ altOn: '\x1B[?1049h',
24
+ altOff: '\x1B[?1049l',
25
+ altScrollOn: '\x1B[?1007h', // xterm alternate scroll:alt 屏内滚轮转发 ↑/↓(配合 onKey/onRunningKey 的 ↑/↓ 滚动)
26
+ altScrollOff: '\x1B[?1007l',
27
+ cursorShow: '\x1B[?25h',
28
+ clearLine: '\x1B[2K',
29
+ home: '\x1B[H',
30
+ };
31
+ /** CUP(光标绝对定位),钳到 >=1。 */
32
+ function cup(row, col) {
33
+ return `\x1B[${Math.max(1, Math.round(row))};${Math.max(1, Math.round(col))}H`;
34
+ }
35
+ export function getGeo(fh = footerH) {
36
+ const rows = stdout.rows || 24;
37
+ const cols = stdout.columns || 80;
38
+ const f = Math.max(1, Math.min(fh, rows - 1));
39
+ return {
40
+ rows,
41
+ cols,
42
+ footerH: f,
43
+ contentTop: 1,
44
+ contentBottom: Math.max(1, rows - f),
45
+ };
46
+ }
47
+ /** (重)设滚动区域 [1, rows-footerH];设完 \x1B[H 归位(conhost 设 DECSTBM 时光标留旧位置行为未定义)。 */
48
+ export function setRegion(fh) {
49
+ const rows = stdout.rows || 24;
50
+ const oldBottom = Math.max(1, rows - footerH);
51
+ const g = getGeo(fh);
52
+ footerH = g.footerH;
53
+ if (active) {
54
+ stdout.write(`\x1B[1;${g.contentBottom}r`); // DECSTBM: top=1, bottom=contentBottom
55
+ // 底栏缩小:原底栏行变回内容区,清掉残留底栏文本(否则提交多行后旧输入残留在底部)
56
+ if (g.contentBottom > oldBottom) {
57
+ let p = '';
58
+ for (let r = oldBottom + 1; r <= g.contentBottom; r++) {
59
+ p += cup(r, 1) + esc.clearLine;
60
+ }
61
+ stdout.write(p);
62
+ }
63
+ stdout.write(esc.home);
64
+ }
65
+ if (contentRow > g.contentBottom)
66
+ contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
67
+ return g;
68
+ }
69
+ export function contentMode() {
70
+ if (!active)
71
+ return;
72
+ // 滚动回看时回内容区底(光标不入输入框,viewport 锁历史)
73
+ stdout.write(cup(scrollOffset === 0 ? contentRow : getGeo().contentBottom, scrollOffset === 0 ? contentCol : 1));
74
+ }
75
+ /**
76
+ * 内容区正文写:CUP 到续写位 + 写出 + 按字符宽度/折行更新续写位 + 同步入 content 缓冲(供滚动回看)。
77
+ * 滚动区域内写满自动在区域内滚动,底栏在区域外不被顶。非 TTY / 未激活:直接 stdout.write(内联退化)。
78
+ *
79
+ * 逐字符分类(按码点取字符,SGR 序列整段识别):SGR 码 → 0 宽跟踪 + content.feedSgr;其他 CSI/OSC → 跳过
80
+ * (callers 理论上不传);\n / 折行 / tab 换行 → 行推进 + content.breakRow;可见字符 → charWidth 跟踪 +
81
+ * content.feedChar。SGR 在终端 0 宽,故续写位只按可见字符推进(与终端实际光标一致)。
82
+ */
83
+ export function contentWrite(s) {
84
+ if (!active || !ui.isTTY) {
85
+ stdout.write(s);
86
+ return;
87
+ }
88
+ // 滚动回看时(scrollOffset>0)只喂缓冲 + 推进续写位,不物理写——否则新流式内容覆盖 viewport 历史行。
89
+ // 回尾(scrollOffset===0)才 cup 到续写位物理写出(== 实时屏)。
90
+ if (scrollOffset === 0)
91
+ stdout.write(cup(contentRow, contentCol) + s);
92
+ const g = getGeo();
93
+ const cols = g.cols;
94
+ const bottom = g.contentBottom;
95
+ const advanceRow = (r) => (r >= bottom ? bottom : r + 1); // 到底则滚动,续写位留底
96
+ let i = 0;
97
+ while (i < s.length) {
98
+ const rest = s.slice(i);
99
+ // SGR 序列 \x1B[...m:0 宽 + 喂缓冲
100
+ const sgr = /^\x1b\[[0-9;]*m/.exec(rest);
101
+ if (sgr) {
102
+ content.feedSgr(sgr[0]);
103
+ i += sgr[0].length;
104
+ continue;
105
+ }
106
+ // 其他 CSI / OSC 序列(兜底跳过,不喂缓冲——callers 不该传控制码)
107
+ if (s[i] === '\x1b') {
108
+ const csi = /^\x1b\[[0-9;]*[A-Za-z]/.exec(rest);
109
+ if (csi) {
110
+ i += csi[0].length;
111
+ continue;
112
+ }
113
+ const osc = /^\x1b\][^\x07]*(\x07|\x1b\\)/.exec(rest);
114
+ if (osc) {
115
+ i += osc[0].length;
116
+ continue;
117
+ }
118
+ i += 1;
119
+ continue;
120
+ }
121
+ const cp = s.codePointAt(i) ?? 0;
122
+ const ch = String.fromCodePoint(cp);
123
+ if (ch === '\n') {
124
+ contentRow = advanceRow(contentRow);
125
+ content.breakRow();
126
+ contentCol = 1;
127
+ i += ch.length;
128
+ continue;
129
+ }
130
+ if (ch === '\r') {
131
+ contentCol = 1;
132
+ i += ch.length;
133
+ continue;
134
+ }
135
+ if (ch === '\t') {
136
+ const next = Math.floor((contentCol - 1) / 8) * 8 + 8 + 1;
137
+ if (next > cols) {
138
+ contentRow = advanceRow(contentRow);
139
+ content.breakRow();
140
+ contentCol = 1;
141
+ }
142
+ else
143
+ contentCol = next;
144
+ i += ch.length;
145
+ continue;
146
+ }
147
+ const cw = charWidth(cp);
148
+ if (cw === 0) {
149
+ i += ch.length;
150
+ continue; // 组合符 / 零宽:不推进
151
+ }
152
+ if (contentCol + cw - 1 > cols) {
153
+ // 当前行放不下:折行
154
+ contentRow = advanceRow(contentRow);
155
+ content.breakRow();
156
+ contentCol = 1;
157
+ }
158
+ contentCol += cw;
159
+ content.feedChar(ch);
160
+ i += ch.length;
161
+ }
162
+ }
163
+ /** 标记当前续写位为一个段(思考段)起点:记屏起点(供擦除定位)+ content 段标记(供缓冲同步删)。 */
164
+ export function beginSegment() {
165
+ segmentStartRow = contentRow;
166
+ content.beginSegment();
167
+ }
168
+ /**
169
+ * 擦除当前段的可见行(逐行 \x1B[2K,不用 ED——ED 会清穿底栏),续写位回段可见顶;并同步 content 缓冲删段。
170
+ *
171
+ * content.eraseSegment() 返回段物理行数(segLines + 末行部分)并从缓冲删除该段——保证滚动回看只看到
172
+ * 折叠标题、不看到已擦的思考原文。该行数 = segRows,据此判段是否滚出过:
173
+ * - segRows > available:段写满触发过区域滚动,整屏都是段内容(前置内容已被滚出),从内容区顶 contentTop 擦;
174
+ * - 否则未滚动:从段起点 segmentStartRow 擦。
175
+ * 不越界到区域外(不擦底栏)。返回擦除的屏幕行数。
176
+ *
177
+ * **滚动回看态(scrollOffset>0)只删缓冲、不物理擦**:段在滚动态经 contentWrite 只喂缓冲、未物理写屏,
178
+ * 屏上无段可见,此时按屏行 \x1B[2K 物理擦会清掉用户正在看的历史视图(思考折叠把整屏擦白,而非在底部默默隐藏)。
179
+ * 故滚动态仅 content.eraseSegment() 删缓冲 + 复位续写位到段起点(使回尾后续写落在折叠标题位),不发任何
180
+ * cup/clearLine;回尾时 repaintViewport 从缓冲重画即显折叠标题。返回 0(未物理擦)。
181
+ */
182
+ export function eraseSegmentBack() {
183
+ if (!active)
184
+ return 0;
185
+ const g = getGeo();
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;
194
+ }
195
+ const available = g.contentBottom - segmentStartRow + 1;
196
+ let start = segRows > available ? g.contentTop : segmentStartRow;
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;
211
+ contentCol = 1;
212
+ stdout.write(cup(contentRow, contentCol));
213
+ return rows;
214
+ }
215
+ /** 清空内容区(保留底栏):全屏清 + 重设区域 + 续写位归 (1,1) + 清缓冲 + 回尾。底栏由调用方随后重画。 */
216
+ export function clearContent() {
217
+ if (!active)
218
+ return;
219
+ stdout.write('\x1B[1;1H\x1B[2J');
220
+ setRegion(footerH);
221
+ contentRow = 1;
222
+ contentCol = 1;
223
+ segmentStartRow = 1;
224
+ scrollOffset = 0;
225
+ content.reset();
226
+ stdout.write(esc.home);
227
+ }
228
+ // ── viewport 滚动回看(Phase 2)──
229
+ /** 是否处于滚动回看态(offset>0,内容区显历史)。prompt 据此在非滚动键时回尾。 */
230
+ export function isScrolled() {
231
+ return scrollOffset > 0;
232
+ }
233
+ /**
234
+ * 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
235
+ * offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
236
+ */
237
+ export function repaintViewport() {
238
+ if (!active)
239
+ return;
240
+ const g = getGeo();
241
+ const h = g.contentBottom;
242
+ const slice = content.sliceFromEnd(scrollOffset, h);
243
+ let p = '';
244
+ for (let r = 1; r <= h; r++) {
245
+ const line = slice[r - 1] ?? '';
246
+ p += cup(r, 1) + esc.clearLine + line;
247
+ }
248
+ stdout.write(p);
249
+ // 光标:offset=0 回续写位(尾行末,contentWrite 维护);offset>0 留内容区底
250
+ stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
251
+ }
252
+ /** 滚动 delta 行(正=往新、负=往旧);钳 [0, max(0, total-contentBottom)];变则重画 + 刷底栏(显指示、光标回输入框)。 */
253
+ export function scrollBy(delta) {
254
+ if (!active)
255
+ return;
256
+ const g = getGeo();
257
+ const total = content.totalRows();
258
+ const maxOff = Math.max(0, total - g.contentBottom);
259
+ const off = Math.max(0, Math.min(scrollOffset + delta, maxOff));
260
+ if (off === scrollOffset)
261
+ return;
262
+ scrollOffset = off;
263
+ repaintViewport();
264
+ repaint();
265
+ }
266
+ /** 回尾(offset=0);仅当原本滚动过才重画(避免每轮 enterRunningMode 闪烁)。 */
267
+ export function resetScroll() {
268
+ if (scrollOffset === 0)
269
+ return;
270
+ scrollOffset = 0;
271
+ repaintViewport();
272
+ repaint();
273
+ }
274
+ // ── 状态行 ──
275
+ /** 组合状态行可见串(带色),按 cols 截断防溢出(不折到输入行)。 */
276
+ function composeStatus(status, cols) {
277
+ const sep = ' ';
278
+ const sepW = sep.length;
279
+ const lead = `◆ `;
280
+ const model = truncateDisplay(status.model, 22);
281
+ const ctx = status.contextBar; // 已带色
282
+ const ctxW = ansiDisplayWidth(ctx);
283
+ // 滚动回看时状态段改显历史指示(无 spinner——滚动只在 INPUT 态)
284
+ const scrolled = scrollOffset > 0;
285
+ const st = scrolled
286
+ ? `历史 ↑${scrollOffset} (PgDn 回底)`
287
+ : (status.spinnerFrame ? status.spinnerFrame + ' ' : '') + status.status;
288
+ const stW = displayWidth(st);
289
+ const fixed = displayWidth(lead) + displayWidth(model) + sepW * 3 + ctxW + stW;
290
+ const cwdBudget = cols - fixed - 1;
291
+ const cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
292
+ return [
293
+ `${ui.brightCyan}${lead}${ui.reset}${ui.bold}${model}${ui.reset}`,
294
+ sep,
295
+ ctx,
296
+ sep,
297
+ `${ui.dim}${cwd}${ui.reset}`,
298
+ sep,
299
+ `${scrolled ? ui.yellow : status.spinnerFrame ? ui.brightMagenta : ui.dim}${st}${ui.reset}`,
300
+ ].join('');
301
+ }
302
+ /** 画状态行(光标回续写位)。RUNNING 态 spinner 频繁调。 */
303
+ export function drawStatusBar(status) {
304
+ if (!active || !base)
305
+ return;
306
+ const s = status ?? { ...base, status: statusText, spinnerFrame };
307
+ const g = getGeo();
308
+ const statusRow = g.contentBottom + 1;
309
+ stdout.write(cup(statusRow, 1) + esc.clearLine + composeStatus(s, g.cols));
310
+ // 回续写位;滚动回看时回内容区底(光标不入输入框,viewport 锁历史)
311
+ stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
312
+ }
313
+ /** 更新易变状态(状态文字 + spinner 帧)并重画状态行。spinner / agent 用。 */
314
+ export function setStatus(status, frame) {
315
+ statusText = status;
316
+ spinnerFrame = frame;
317
+ drawStatusBar();
318
+ }
319
+ /** 更新状态行基线(模型 / context / cwd)。repl 在轮次边界调。 */
320
+ export function setStatusBase(b) {
321
+ base = b;
322
+ }
323
+ // ── 输入区(底栏输入框 + 向上菜单)──
324
+ /**
325
+ * 把逻辑行软折成可视行并开窗(超 maxInputRows 时滑窗保光标可见)。返回可见可视行文本(已折行)+
326
+ * 光标在窗口内行号 + 光标在该可视行的显示列 + 所需输入行数 + 窗口起始全局可视行号。
327
+ *
328
+ * 光标可视位置按各行**实际**显示宽度逐行累加(不按 width×行号 除法):宽字符(CJK=2)在行尾放不下时
329
+ * 整字折到下行、本行留尾空格,简单除法会把光标算偏;逐行累加 displayWidth 才与终端实际光标一致。
330
+ */
331
+ function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
332
+ const W = Math.max(1, cols - promptW);
333
+ const lineVis = lines.map((l) => wrapByDisplayWidth(l, W));
334
+ const flat = [];
335
+ for (const lv of lineVis)
336
+ for (const r of lv)
337
+ flat.push(r);
338
+ const totalVis = flat.length;
339
+ // 光标在其逻辑行内的可视行 / 可视列
340
+ const clRows = lineVis[cursorLine] ?? [''];
341
+ let curVisRow = 0;
342
+ let curVisCol = cursorCol;
343
+ {
344
+ let acc = 0;
345
+ for (let i = 0; i < clRows.length; i++) {
346
+ const rw = displayWidth(clRows[i]);
347
+ if (cursorCol <= acc + rw) {
348
+ curVisRow = i;
349
+ curVisCol = cursorCol - acc;
350
+ break;
351
+ }
352
+ acc += rw;
353
+ curVisRow = i;
354
+ curVisCol = cursorCol - acc;
355
+ }
356
+ }
357
+ // 光标绝对可视行
358
+ let curAbs = 0;
359
+ for (let i = 0; i < cursorLine; i++)
360
+ curAbs += lineVis[i].length;
361
+ curAbs += curVisRow;
362
+ const maxInputRows = Math.max(1, Math.floor(rows * 0.4));
363
+ let startVis = 0;
364
+ if (totalVis > maxInputRows) {
365
+ startVis = Math.max(0, Math.min(curAbs - maxInputRows + 1, totalVis - maxInputRows));
366
+ }
367
+ const showCount = Math.min(maxInputRows, totalVis);
368
+ return {
369
+ visRows: flat.slice(startVis, startVis + showCount),
370
+ visLine: curAbs - startVis,
371
+ cursorVisCol: curVisCol,
372
+ inputRows: showCount,
373
+ startVis,
374
+ };
375
+ }
376
+ /**
377
+ * 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
378
+ * prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
379
+ */
380
+ export function paintInput(view) {
381
+ if (!active || !base)
382
+ return;
383
+ lastView = view;
384
+ const preGeo = getGeo();
385
+ // 1. 擦旧菜单(用上次记录的起始行 + 行数,与本次几何无关——setRegion 不动屏幕内容)
386
+ if (lastMenuRows > 0) {
387
+ let p = '';
388
+ for (let i = 0; i < lastMenuRows; i++) {
389
+ p += cup(lastMenuStartRow + i, 1) + esc.clearLine;
390
+ }
391
+ stdout.write(p);
392
+ lastMenuRows = 0;
393
+ }
394
+ // 2. 算输入可视行(软折行)+ 必要时 setRegion。dim=运行态占位:单行不折行。
395
+ const promptW = displayWidth(view.prompt);
396
+ const vis = view.dim
397
+ ? {
398
+ visRows: [view.lines[0] ?? ''],
399
+ visLine: 0,
400
+ cursorVisCol: 0,
401
+ inputRows: 1,
402
+ startVis: 0,
403
+ }
404
+ : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
405
+ const needFooterH = 1 + vis.inputRows;
406
+ let g = preGeo;
407
+ if (needFooterH !== footerH)
408
+ g = setRegion(needFooterH);
409
+ // 2b. 重画内容末行(底栏上一行)从缓冲:防 WT 边距漏影(状态行重复到该行),并保证该行显正确内容
410
+ {
411
+ const slice = content.sliceFromEnd(scrollOffset, g.contentBottom);
412
+ const line = slice[g.contentBottom - 1] ?? '';
413
+ stdout.write(cup(g.contentBottom, 1) + esc.clearLine + line);
414
+ }
415
+ // 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
416
+ const statusRow = g.contentBottom + 1;
417
+ const status = { ...base, status: statusText, spinnerFrame };
418
+ stdout.write(cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols));
419
+ // 4. 输入行(g.contentBottom+2 .. rows)——按可视行画,首行带 prompt、其余缩进 promptW
420
+ const firstInputRow = g.contentBottom + 2;
421
+ const inputRowsAvail = g.footerH - 1;
422
+ const indent = ' '.repeat(promptW);
423
+ for (let i = 0; i < inputRowsAvail; i++) {
424
+ const line = vis.visRows[i] ?? '';
425
+ const r = firstInputRow + i;
426
+ const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
427
+ const text = view.dim
428
+ ? `${ui.dim}${prefix}${line}${ui.reset}`
429
+ : `${prefix}${line}`;
430
+ stdout.write(cup(r, 1) + esc.clearLine + text);
431
+ }
432
+ // 5. 向上菜单(画在内容区底,底栏正上方)
433
+ if (view.menu && view.menu.lines.length > 0) {
434
+ const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
435
+ const menuStart = g.contentBottom - menuRows + 1;
436
+ let p = '';
437
+ for (let i = 0; i < menuRows; i++) {
438
+ p += cup(menuStart + i, 1) + esc.clearLine + view.menu.lines[i];
439
+ }
440
+ stdout.write(p);
441
+ lastMenuStartRow = menuStart;
442
+ lastMenuRows = menuRows;
443
+ }
444
+ // 6. 光标
445
+ if (view.dim) {
446
+ // 滚动回看时归内容区底(dim=运行态占位,光标不入输入框,viewport 锁历史)
447
+ stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
448
+ }
449
+ else {
450
+ const r = firstInputRow + vis.visLine;
451
+ const col = promptW + vis.cursorVisCol + 1;
452
+ stdout.write(cup(r, col));
453
+ }
454
+ }
455
+ /**
456
+ * 运行态 typeahead 回显:定向写输入行(底栏输入框),把 dim 占位换成已打字文本(无打字时仍显 placeholder)。
457
+ * 只 cup 输入行 + clearLine + dim 文本 + 归位——不调 setRegion(运行中禁多行,避免 DECSTBM 抖动)、
458
+ * 不重画状态行/contentBottom、不用 ED。同步 lastView 为 dim 视图,使 scrollBy/resize 的 repaint 仍显当前回显。
459
+ * 光标归续写位(滚动回看时归内容区底)——typeahead 无可见输入框光标,避开与 contentWrite/drawStatusBar 争用。
460
+ */
461
+ export function paintRunningInputEcho(text, placeholder) {
462
+ if (!active || !base)
463
+ return;
464
+ const g = getGeo();
465
+ const inputRow = g.contentBottom + 2; // 运行态 footerH 恒 2:状态行(contentBottom+1)+ 输入行(contentBottom+2)
466
+ const shown = text.length > 0 ? text : placeholder;
467
+ stdout.write(cup(inputRow, 1) + esc.clearLine + `${ui.dim}❯ ${shown}${ui.reset}`);
468
+ // 同步 lastView:dim 视图(lines=回显文本),使滚动/resize 的 repaint 不擦掉已打字
469
+ lastView = {
470
+ prompt: '❯ ',
471
+ lines: [shown],
472
+ cursorLine: 0,
473
+ cursorCol: 0,
474
+ menu: null,
475
+ dim: true,
476
+ };
477
+ // 光标归续写位(滚动回看时归内容区底)
478
+ stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
479
+ }
480
+ /** 重画当前视图(resize / 内部用)。 */
481
+ export function repaint() {
482
+ if (!active || !base)
483
+ return;
484
+ if (lastView)
485
+ paintInput(lastView);
486
+ else
487
+ drawStatusBar();
488
+ }
489
+ /** 进入输入态:画空输入框 + 状态行,光标入输入框。 */
490
+ export function enterInputMode(status = '空闲') {
491
+ mode = 'input';
492
+ statusText = status;
493
+ spinnerFrame = undefined;
494
+ if (active && base) {
495
+ setRegion(2);
496
+ paintInput({
497
+ prompt: '❯ ',
498
+ lines: [''],
499
+ cursorLine: 0,
500
+ cursorCol: 0,
501
+ menu: null,
502
+ });
503
+ }
504
+ }
505
+ /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 2。新轮回尾(确保新内容可见)。 */
506
+ export function enterRunningMode(status, placeholder) {
507
+ mode = 'running';
508
+ statusText = status;
509
+ spinnerFrame = undefined;
510
+ resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
511
+ if (active && base) {
512
+ setRegion(2);
513
+ paintInput({
514
+ prompt: '❯ ',
515
+ lines: [placeholder],
516
+ cursorLine: 0,
517
+ cursorCol: 0,
518
+ menu: null,
519
+ dim: true,
520
+ });
521
+ contentMode();
522
+ }
523
+ }
524
+ // ── alt screen 生命周期 + 钩子 ──
525
+ export function enterAltScreen() {
526
+ if (active || !ui.isTTY)
527
+ return;
528
+ active = true;
529
+ stdout.write(esc.altOn);
530
+ stdout.write(esc.altScrollOn); // alt 屏滚轮转发 ↑/↓(滚轮滚动靠此 + onKey/onRunningKey 的 ↑/↓ 滚动)
531
+ setRegion(2);
532
+ contentRow = 1;
533
+ contentCol = 1;
534
+ segmentStartRow = 1;
535
+ scrollOffset = 0;
536
+ content.reset();
537
+ exitHandler = () => exitAltScreen();
538
+ process.on('exit', exitHandler);
539
+ sigwinchHandler = () => {
540
+ if (!active)
541
+ return;
542
+ if (resizeTimer)
543
+ clearTimeout(resizeTimer);
544
+ resizeTimer = setTimeout(() => {
545
+ resizeTimer = null;
546
+ if (!active)
547
+ return;
548
+ const g = getGeo(footerH);
549
+ if (contentRow > g.contentBottom)
550
+ contentRow = g.contentBottom;
551
+ // offset 钳到新 maxOff(高度缩可能使旧 offset 失效)
552
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
553
+ if (scrollOffset > maxOff)
554
+ scrollOffset = maxOff;
555
+ setRegion(footerH); // 用新 rows 重设区域(contentBottom 变)
556
+ repaintViewport(); // 内容区按新高度 + 当前 offset 重画(物理行不 reflow)
557
+ repaint(); // 底栏重画
558
+ }, 100);
559
+ resizeTimer?.unref?.();
560
+ };
561
+ process.on('SIGWINCH', sigwinchHandler);
562
+ }
563
+ /** 复原:复位 margins + 显光标 + 退 alt + 还原 raw。幂等。 */
564
+ export function exitAltScreen() {
565
+ if (!active)
566
+ return;
567
+ active = false;
568
+ // raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
569
+ try {
570
+ stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
571
+ }
572
+ catch {
573
+ // 非 TTY / 不支持:忽略
574
+ }
575
+ try {
576
+ stdout.write('\x1B[r'); // 复位 DECSTBM margins
577
+ stdout.write(esc.cursorShow);
578
+ stdout.write(esc.altScrollOff); // 关 alt 屏滚轮转发
579
+ stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
580
+ }
581
+ catch {
582
+ // 忽略
583
+ }
584
+ if (exitHandler) {
585
+ try {
586
+ process.removeListener('exit', exitHandler);
587
+ }
588
+ catch {
589
+ // 忽略
590
+ }
591
+ exitHandler = null;
592
+ }
593
+ if (sigwinchHandler) {
594
+ try {
595
+ process.removeListener('SIGWINCH', sigwinchHandler);
596
+ }
597
+ catch {
598
+ // 忽略
599
+ }
600
+ sigwinchHandler = null;
601
+ }
602
+ if (resizeTimer) {
603
+ clearTimeout(resizeTimer);
604
+ resizeTimer = null;
605
+ }
606
+ }
607
+ export function isActive() {
608
+ return active;
609
+ }