mocode-ai 0.1.7 → 0.1.8
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 +19 -28
- package/dist/agent/core.js +9 -4
- package/dist/config/index.js +1 -0
- package/dist/context/encoders/_util.js +40 -0
- package/dist/context/encoders/code.js +77 -0
- package/dist/context/encoders/doc.js +28 -0
- package/dist/context/encoders/graph.js +31 -0
- package/dist/context/encoders/index.js +24 -2
- package/dist/context/encoders/log.js +52 -0
- package/dist/context/encoders/memory.js +63 -0
- package/dist/context/encoders/search.js +69 -0
- package/dist/context/encoders/summary.js +26 -0
- package/dist/context/encoders/table.js +64 -0
- package/dist/context/encoders/tree.js +86 -0
- package/dist/repl/index.js +12 -12
- package/dist/session/compact.js +49 -18
- package/dist/ui/layout.js +148 -50
- package/dist/ui/prompt.js +4 -4
- package/dist/ui/render.js +20 -0
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Tree Encoder(glob):扁平路径列表 → 按目录分组缩进树。
|
|
3
|
+
*
|
|
4
|
+
* 输入:glob 返回的路径(每行一条,\n 拼接),可能带尾部 `... (共 N 个,仅显示前 200)`。
|
|
5
|
+
* 输出:目录头(以 `/` 结尾)+ 其下 2 空格缩进的文件,顶部 `# N files · tree-encoded` 计数。
|
|
6
|
+
*
|
|
7
|
+
* 不变量(离线脚本断言):路径条数与集合保真——每个原始路径可从树还原:
|
|
8
|
+
* 目录头 `dir/` + 其下缩进行 ` file` → `dir/file`;根文件(无缩进、不以 `/` 结尾)→ 自身。
|
|
9
|
+
* 小输入(≤1 路径,含 `无匹配文件` 单行)原样返回:tree 无收益且避免计数头开销。
|
|
10
|
+
* 路径分隔符归一化(`\`→`/`):Windows 下 glob 可能返 `\`,树内统一 `/`。
|
|
11
|
+
*/
|
|
12
|
+
function normalizeSep(p) {
|
|
13
|
+
return p.replace(/\\/g, '/');
|
|
14
|
+
}
|
|
15
|
+
export const treeEncoder = {
|
|
16
|
+
kind: 'tree',
|
|
17
|
+
encode({ output }) {
|
|
18
|
+
const lines = output.split('\n');
|
|
19
|
+
// 分离路径与尾部标记(glob 的 `... (共 N 个...)`)。首个非路径行起全部视作 tail 原样保留。
|
|
20
|
+
const paths = [];
|
|
21
|
+
const tail = [];
|
|
22
|
+
let inTail = false;
|
|
23
|
+
for (const l of lines) {
|
|
24
|
+
if (!l)
|
|
25
|
+
continue;
|
|
26
|
+
if (!inTail && (l.startsWith('...') || /^\(共/.test(l))) {
|
|
27
|
+
inTail = true;
|
|
28
|
+
}
|
|
29
|
+
if (inTail)
|
|
30
|
+
tail.push(l);
|
|
31
|
+
else
|
|
32
|
+
paths.push(l);
|
|
33
|
+
}
|
|
34
|
+
if (paths.length <= 1) {
|
|
35
|
+
// ≤1 路径:tree 无收益(含 `无匹配文件`),原样返回。
|
|
36
|
+
return {
|
|
37
|
+
text: output,
|
|
38
|
+
meta: {
|
|
39
|
+
kind: 'tree',
|
|
40
|
+
originalLen: output.length,
|
|
41
|
+
encodedLen: output.length,
|
|
42
|
+
note: '≤1 path → passthrough',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// 按目录分组(保留首次出现顺序,保还原后顺序与原一致)
|
|
47
|
+
const groups = new Map();
|
|
48
|
+
const order = [];
|
|
49
|
+
for (const p of paths) {
|
|
50
|
+
const norm = normalizeSep(p);
|
|
51
|
+
const idx = norm.lastIndexOf('/');
|
|
52
|
+
const dir = idx >= 0 ? norm.slice(0, idx + 1) : ''; // 含尾斜杠
|
|
53
|
+
const file = idx >= 0 ? norm.slice(idx + 1) : norm;
|
|
54
|
+
if (!groups.has(dir)) {
|
|
55
|
+
groups.set(dir, []);
|
|
56
|
+
order.push(dir);
|
|
57
|
+
}
|
|
58
|
+
groups.get(dir).push(file);
|
|
59
|
+
}
|
|
60
|
+
const out = [`# ${paths.length} files · tree-encoded`];
|
|
61
|
+
for (const dir of order) {
|
|
62
|
+
const files = groups.get(dir);
|
|
63
|
+
if (dir === '') {
|
|
64
|
+
for (const f of files)
|
|
65
|
+
out.push(f);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
out.push(dir);
|
|
69
|
+
for (const f of files)
|
|
70
|
+
out.push(` ${f}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (tail.length)
|
|
74
|
+
out.push(...tail);
|
|
75
|
+
const text = out.join('\n');
|
|
76
|
+
return {
|
|
77
|
+
text,
|
|
78
|
+
meta: {
|
|
79
|
+
kind: 'tree',
|
|
80
|
+
originalLen: output.length,
|
|
81
|
+
encodedLen: text.length,
|
|
82
|
+
note: `${paths.length} files / ${order.length} dirs`,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
package/dist/repl/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import * as mouse from '../ui/mouse.js';
|
|
|
13
13
|
import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
|
|
14
14
|
import { promptIntervention } from '../ui/intervention.js';
|
|
15
15
|
import { tools } from '../tools/registry.js';
|
|
16
|
-
import { estimateMessagesTokens,
|
|
16
|
+
import { estimateMessagesTokens, } from '../llm/index.js';
|
|
17
17
|
import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
|
|
18
18
|
import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
|
|
19
19
|
import { listSkills, effectiveSystemPrompt } from '../skills/index.js';
|
|
@@ -86,11 +86,11 @@ async function askLine(prompt) {
|
|
|
86
86
|
rl.close();
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
/** /context 的用量条(详情版,进内容区)
|
|
89
|
+
/** /context 的用量条(详情版,进内容区):只算对话内容(不含 system prompt),方便用户感知自己发了多少、agent 回复了多少。 */
|
|
90
90
|
function renderContextBar(history) {
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
91
|
+
// 过滤掉 system 消息,只算对话内容
|
|
92
|
+
const dialog = history.filter(m => m.role !== 'system');
|
|
93
|
+
const est = estimateMessagesTokens(dialog);
|
|
94
94
|
const win = config.contextWindowTokens;
|
|
95
95
|
const pct = Math.min(1, est / win);
|
|
96
96
|
const W = 10;
|
|
@@ -101,11 +101,12 @@ function renderContextBar(history) {
|
|
|
101
101
|
const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
|
|
102
102
|
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}`;
|
|
103
103
|
}
|
|
104
|
-
/** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
|
|
104
|
+
/** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
|
|
105
|
+
* 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
|
|
105
106
|
function renderContextBarInline(history) {
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
107
|
+
// 过滤掉 system 消息,只算对话内容
|
|
108
|
+
const dialog = history.filter(m => m.role !== 'system');
|
|
109
|
+
const est = estimateMessagesTokens(dialog);
|
|
109
110
|
const win = config.contextWindowTokens;
|
|
110
111
|
const pct = Math.min(1, est / win);
|
|
111
112
|
const W = 10;
|
|
@@ -189,9 +190,8 @@ function onRunningKey(_str, key) {
|
|
|
189
190
|
}
|
|
190
191
|
// 用户在交互(非滚动键)→ 暂停流式物理写,避免光标去 contentRow 扰动 IME 候选窗(停手后自动 flush)
|
|
191
192
|
layout.setUserActive();
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
layout.resetScroll();
|
|
193
|
+
// 滚动回看时打字 / 编辑(typeahead)不回尾——保持历史视图,便于运行中边看历史边预输入;
|
|
194
|
+
// 回尾时机:Enter 在运行态是 no-op,真正回尾发生在 agent 结束后 INPUT 态按 Enter 提交(见 prompt.ts submit 前)。
|
|
195
195
|
// Ctrl+C 4 层语义(RUNNING 态):有 typeahead → 清空(层 1,不中断);空 → abort(层 2,中断 agent)。
|
|
196
196
|
// 两次 Ctrl+C 才中断(先清 typeahead 再 abort),与 INPUT 态 onCtrlC 的 fish 式一致。
|
|
197
197
|
// raw 模式下 Ctrl+C 是按键不触发 SIGINT;signal 经 executeTool 串进工具,run_command/web_fetch 即时被杀。
|
package/dist/session/compact.js
CHANGED
|
@@ -23,6 +23,47 @@ export function truncateMid(text, max) {
|
|
|
23
23
|
const out = text.slice(0, head) + marker + text.slice(text.length - tail);
|
|
24
24
|
return out.length > max ? out.slice(0, max) : out;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Provenance 压缩:旧 tool_calls.arguments 超长时,把大字符串字段替换为 "<N 字符,已省略>" stub,
|
|
28
|
+
* 保留 path(/rollback planRollback 靠 JSON.parse(arguments).path 找文件,见 rollback/index.ts)+ 其余短字段。
|
|
29
|
+
* 比 truncateMid 的 head+tail 片段更短且更易读:LLM/摘要器看到 "<5000 字符,已省略>" 即知"写过 5000 字符",
|
|
30
|
+
* 而非混乱的 head+tail 片段。
|
|
31
|
+
*
|
|
32
|
+
* 保:JSON 合法(parse/stringify 失败均原样返回)+ tool_call_id 配对不动(只改 arguments 内容,不改
|
|
33
|
+
* tool_calls 数组结构)+ path 永不省(rollback 依赖)。永不抛错(对齐「调度器永不抛错」契约)。
|
|
34
|
+
*
|
|
35
|
+
* 触发:整体 arguments > MAX_OLD_TOOL_STUB 才进(同原 truncateMid 门槛);字段级也 > MAX_OLD_TOOL_STUB 才 stub。
|
|
36
|
+
*/
|
|
37
|
+
function stubToolCallArguments(argsRaw) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(argsRaw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return argsRaw; // 非合法 JSON(模型偶发):不碰
|
|
44
|
+
}
|
|
45
|
+
if (!parsed || typeof parsed !== 'object')
|
|
46
|
+
return argsRaw;
|
|
47
|
+
const obj = parsed;
|
|
48
|
+
let changed = false;
|
|
49
|
+
for (const k of Object.keys(obj)) {
|
|
50
|
+
if (k === 'path')
|
|
51
|
+
continue; // path 永不省:/rollback planRollback 靠它定位文件
|
|
52
|
+
const v = obj[k];
|
|
53
|
+
if (typeof v === 'string' && v.length > MAX_OLD_TOOL_STUB) {
|
|
54
|
+
obj[k] = `<${v.length} 字符,已省略>`;
|
|
55
|
+
changed = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!changed)
|
|
59
|
+
return argsRaw;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.stringify(obj);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return argsRaw; // stringify 失败(理论不会):不碰
|
|
65
|
+
}
|
|
66
|
+
}
|
|
26
67
|
/**
|
|
27
68
|
* push-time 第一层:工具结果进 history 前裁到 MAX_HISTORY_RESULT。
|
|
28
69
|
* 显示层(summarizeToolResult)仍用原 output,不受影响。
|
|
@@ -208,8 +249,8 @@ export async function compactHistory(history, opts) {
|
|
|
208
249
|
// 第一层:微压缩——旧区原地截短(保 tool_call_id,无 LLM 调用)
|
|
209
250
|
// 覆盖三类大字段,均只裁模型/工具产物,不动 user 原话与 system(摘要):
|
|
210
251
|
// ① tool 结果 content;
|
|
211
|
-
// ② 旧 assistant 的 tool_calls.arguments ——
|
|
212
|
-
//
|
|
252
|
+
// ② 旧 assistant 的 tool_calls.arguments —— provenance stub:整体超长才进,大字段
|
|
253
|
+
// 替换为 "<N 字符,已省略>"(保 path + JSON 合法,见 stubToolCallArguments);
|
|
213
254
|
// ③ 旧 assistant 正文 content(模型长解释,回看价值低)。
|
|
214
255
|
let microcompactDone = false;
|
|
215
256
|
for (const g of oldGroups) {
|
|
@@ -227,27 +268,17 @@ export async function compactHistory(history, opts) {
|
|
|
227
268
|
as.content = truncateMid(as.content, MAX_OLD_TOOL_STUB);
|
|
228
269
|
microcompactDone = true;
|
|
229
270
|
}
|
|
230
|
-
// ② tool_calls
|
|
271
|
+
// ② tool_calls 参数:整体超长才进,provenance stub 大字段(保 path + JSON 合法)。
|
|
272
|
+
// stubToolCallArguments:大字符串字段 → "<N 字符,已省略>";path 永不省(/rollback 依赖)。
|
|
231
273
|
if (Array.isArray(as.tool_calls)) {
|
|
232
274
|
for (const tc of as.tool_calls) {
|
|
233
275
|
const args = tc?.function?.arguments;
|
|
234
276
|
if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
|
|
235
277
|
continue;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const v = parsed[k];
|
|
241
|
-
if (typeof v === 'string' && v.length > MAX_OLD_TOOL_STUB) {
|
|
242
|
-
parsed[k] = truncateMid(v, MAX_OLD_TOOL_STUB);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
tc.function.arguments = JSON.stringify(parsed);
|
|
246
|
-
microcompactDone = true;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
// arguments 非合法 JSON(模型偶发):不裁,沿用「调度器永不抛错」
|
|
278
|
+
const stubbed = stubToolCallArguments(args);
|
|
279
|
+
if (stubbed !== args) {
|
|
280
|
+
tc.function.arguments = stubbed;
|
|
281
|
+
microcompactDone = true;
|
|
251
282
|
}
|
|
252
283
|
}
|
|
253
284
|
}
|
package/dist/ui/layout.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { stdin, stdout } from 'node:process';
|
|
2
|
-
import {
|
|
2
|
+
import { appendFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } from './render.js';
|
|
3
6
|
import { ui } from './theme.js';
|
|
4
7
|
import * as content from './content.js';
|
|
5
8
|
import * as mouse from './mouse.js';
|
|
@@ -7,11 +10,17 @@ import { renderMarkdown } from './markdown.js';
|
|
|
7
10
|
// ── 内部状态 ──
|
|
8
11
|
let active = false;
|
|
9
12
|
let mode = 'input';
|
|
10
|
-
let footerH =
|
|
13
|
+
let footerH = 5; // 1 虚拟空行 + 1 状态行 + 1 上线 + 输入行数 + 1 下线(虚拟空行隔开内容与状态栏,上下线框住输入区)
|
|
11
14
|
let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
|
|
12
15
|
let contentCol = 1; // 续写位列(1-based)
|
|
16
|
+
// 上一次 paintLiveAtCursor 实际画帧的屏坐标(0=未画)。clearLiveAtCursor 清"这行"而非当前续写位——
|
|
17
|
+
// 防 spinner 运行期间续写位漂移时清错行、旧帧行残留(见 557e678 移除 isStreamingPaused 后的间歇性 frame 泄漏)。
|
|
18
|
+
let frameRow = 0;
|
|
19
|
+
let frameCol = 0;
|
|
13
20
|
let segmentStartRow = 1; // 当前 md 段起始屏行(供 contentWriteMd 定位段末续写位;段内行数由 content 段标记跟踪)
|
|
14
21
|
let scrollOffset = 0; // 滚动回看距尾行数(0=尾,跟随新内容);>0 时 viewport 显历史、状态行显滚动指示
|
|
22
|
+
let scrollLockUntil = 0; // 发消息轮首滚动锁(绝对时间戳 ms,0=未锁):吸收 stdin 残留滚轮事件,防 resetScroll 回尾后被重新滚上去
|
|
23
|
+
const SCROLL_LOCK_MS = 400; // 锁时长:覆盖 OS 缓冲残留 + 常规滚轮惯性;LLM TTFB 多 >200ms,不影响轮中后段滚动
|
|
15
24
|
let base = null;
|
|
16
25
|
let statusText = '';
|
|
17
26
|
let spinnerFrame;
|
|
@@ -86,25 +95,27 @@ export function setRegion(fh) {
|
|
|
86
95
|
contentRow = g.contentBottom; // 底栏撑高挤掉内容:钳到新区底
|
|
87
96
|
return g;
|
|
88
97
|
}
|
|
89
|
-
/** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+
|
|
98
|
+
/** 运行态真光标(隐藏)的归位点 = 输入框光标位:行 = 动态 contentBottom+4(resize 安全),列对齐 renderDimInputRow 的假光标(空→3,有字→❯+截断文本宽+1)。供 IME 锚定。 */
|
|
90
99
|
function runningCaretPos() {
|
|
91
100
|
const g = getGeo();
|
|
92
101
|
const text = lastView?.dim ? lastView.lines[0] ?? '' : '';
|
|
93
102
|
const contentW = Math.max(0, g.cols - 3); // ❯ =2 + 光标=1
|
|
94
|
-
const w = displayWidth(
|
|
95
|
-
return { row: g.contentBottom +
|
|
103
|
+
const w = displayWidth(truncateDisplayHead(text, contentW));
|
|
104
|
+
return { row: g.contentBottom + 4, col: Math.min(g.cols, 2 + w + 1) };
|
|
96
105
|
}
|
|
97
106
|
export function contentMode() {
|
|
98
107
|
if (!active)
|
|
99
108
|
return;
|
|
100
109
|
const g = getGeo();
|
|
101
|
-
if (mode === 'running'
|
|
102
|
-
//
|
|
110
|
+
if (mode === 'running') {
|
|
111
|
+
// 运行态(回尾 / 滚动回看均):真光标归输入框光标位(供 IME 锚定,气泡不跟流式跑)。
|
|
112
|
+
// 滚动态也归输入框——否则上滑看历史时打字,IME 候选气泡会锚到内容区底而非输入框
|
|
113
|
+
// (conhost IME 不跟随 cup 后续移动,须让打字前光标已在输入框)。
|
|
103
114
|
const p = runningCaretPos();
|
|
104
115
|
stdout.write(cup(p.row, p.col));
|
|
105
116
|
}
|
|
106
117
|
else {
|
|
107
|
-
// INPUT
|
|
118
|
+
// INPUT 态:回尾归续写位,滚动回看归内容区底(viewport 锁历史;INPUT 态无 IME 锚定需求)
|
|
108
119
|
stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
|
|
109
120
|
}
|
|
110
121
|
}
|
|
@@ -252,10 +263,10 @@ export function contentWrite(s) {
|
|
|
252
263
|
scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
|
|
253
264
|
}
|
|
254
265
|
}
|
|
255
|
-
// 物理写(回尾 offset=0
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
if (scrollOffset === 0
|
|
266
|
+
// 物理写(回尾 offset=0):cup 写起点 + s + (pending 时补 \n 提交换行)+ (运行态 cup 回输入框)。
|
|
267
|
+
// 打字中不再暂停物理写——单次 write 结尾 cup 回 runningCaretPos(输入框),IME 锚定不动(跟踪每次 write
|
|
268
|
+
// 最终位置);旧设计 isStreamingPaused 暂停写致流式卡顿,现单次写归位已无需暂停。
|
|
269
|
+
if (scrollOffset === 0) {
|
|
259
270
|
let out = cup(startRow, startCol) + s;
|
|
260
271
|
if (pendingWrap)
|
|
261
272
|
out += '\n'; // 滚动区域底行触发 DECSTBM 上滚、中段 LF 下移到 (下一行,1)
|
|
@@ -290,7 +301,7 @@ function commitMd() {
|
|
|
290
301
|
*
|
|
291
302
|
* 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
|
|
292
303
|
* 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
|
|
293
|
-
*
|
|
304
|
+
* 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
|
|
294
305
|
*/
|
|
295
306
|
export function contentWriteMd(s) {
|
|
296
307
|
if (!active || !ui.isTTY) {
|
|
@@ -319,8 +330,8 @@ export function contentWriteMd(s) {
|
|
|
319
330
|
scrollOffset = Math.max(0, Math.min(scrollOffset + delta, maxOff));
|
|
320
331
|
}
|
|
321
332
|
}
|
|
322
|
-
if (scrollOffset === 0
|
|
323
|
-
repaintViewport();
|
|
333
|
+
if (scrollOffset === 0) {
|
|
334
|
+
repaintViewport(); // 单次 write 结尾 cup 回 runningCaretPos(运行态),IME 锚输入框;打字中不再暂停
|
|
324
335
|
if (mode === 'running') {
|
|
325
336
|
const p = runningCaretPos();
|
|
326
337
|
stdout.write(cup(p.row, p.col));
|
|
@@ -351,8 +362,11 @@ export function clearContent() {
|
|
|
351
362
|
setRegion(footerH);
|
|
352
363
|
contentRow = 1;
|
|
353
364
|
contentCol = 1;
|
|
365
|
+
frameRow = 0;
|
|
366
|
+
frameCol = 0;
|
|
354
367
|
segmentStartRow = 1;
|
|
355
368
|
scrollOffset = 0;
|
|
369
|
+
scrollLockUntil = 0;
|
|
356
370
|
mdActive = false;
|
|
357
371
|
mdBuf = '';
|
|
358
372
|
content.reset();
|
|
@@ -378,14 +392,24 @@ export function repaintViewport() {
|
|
|
378
392
|
const line = slice[r - 1] ?? '';
|
|
379
393
|
p += cup(r, 1) + esc.clearLine + line;
|
|
380
394
|
}
|
|
395
|
+
// 光标:合并进同一 write(若拆成两次 stdout.write,行写完光标会暂留 contentRow/contentBottom,
|
|
396
|
+
// 流式每 chunk 调一次 → contentBottom 频繁现块状光标白块;打字第一键赶上这瞬 IME 候选气泡锚到 contentBottom)。
|
|
397
|
+
// 运行态(回尾 / 滚动均)归输入框 runningCaretPos(供 IME 锚定);INPUT 态回尾归续写位 / 滚动归内容区底。
|
|
398
|
+
if (mode === 'running') {
|
|
399
|
+
const c = runningCaretPos();
|
|
400
|
+
p += cup(c.row, c.col);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
p += cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1);
|
|
404
|
+
}
|
|
381
405
|
stdout.write(p);
|
|
382
|
-
// 光标:offset=0 回续写位(尾行末,contentWrite 维护);offset>0 留内容区底
|
|
383
|
-
stdout.write(cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1));
|
|
384
406
|
}
|
|
385
407
|
/** 滚动 delta 行(正=往新、负=往旧);钳 [0, max(0, total-contentBottom)];变则重画 + 刷底栏(显指示、光标回输入框)。 */
|
|
386
408
|
export function scrollBy(delta) {
|
|
387
409
|
if (!active)
|
|
388
410
|
return;
|
|
411
|
+
if (Date.now() < scrollLockUntil)
|
|
412
|
+
return; // 轮首滚动锁:吸收发消息前后 stdin 残留滚轮事件,保 agent 输出从底部开始
|
|
389
413
|
const g = getGeo();
|
|
390
414
|
const total = content.totalRows();
|
|
391
415
|
const maxOff = Math.max(0, total - g.contentBottom);
|
|
@@ -404,6 +428,21 @@ export function resetScroll() {
|
|
|
404
428
|
repaintViewport();
|
|
405
429
|
repaint();
|
|
406
430
|
}
|
|
431
|
+
/** 发消息轮首短时锁住滚动:吸收 stdin 残留滚轮事件(发消息前后的滚轮惯性 / OS 缓冲延迟到达),
|
|
432
|
+
* 防 resetScroll 回尾后被 onRunningKey 接到的残留事件重新滚上去——致 agent 输出进缓冲、显示在历史区。
|
|
433
|
+
* 锁只挡 scrollBy(滚轮 / PgUp-PgDn);不影响 contentWrite 写屏(offset=0 时照常物理写,agent 输出从底部开始)。
|
|
434
|
+
* 默认 SCROLL_LOCK_MS 后自动解锁;enterInputMode(轮末)也清锁。用户轮中后段仍可上滑(满足"输出时能看历史")。 */
|
|
435
|
+
export function lockScrollToBottom(ms = SCROLL_LOCK_MS) {
|
|
436
|
+
scrollLockUntil = Date.now() + ms;
|
|
437
|
+
}
|
|
438
|
+
/** 解锁(轮末 enterInputMode / 测试用)。 */
|
|
439
|
+
export function unlockScroll() {
|
|
440
|
+
scrollLockUntil = 0;
|
|
441
|
+
}
|
|
442
|
+
/** 轮首滚动锁是否生效(测试用)。 */
|
|
443
|
+
export function isScrollLocked() {
|
|
444
|
+
return Date.now() < scrollLockUntil;
|
|
445
|
+
}
|
|
407
446
|
// ── 状态行 ──
|
|
408
447
|
/** 组合状态行可见串(带色),按 cols 截断防溢出(不折到输入行)。 */
|
|
409
448
|
function composeStatus(status, cols) {
|
|
@@ -458,14 +497,14 @@ function composeStatus(status, cols) {
|
|
|
458
497
|
export function drawStatusBar(status) {
|
|
459
498
|
if (!active || !base)
|
|
460
499
|
return;
|
|
461
|
-
if (isStreamingPaused())
|
|
462
|
-
return; // 用户打字中:状态行暂不刷(避免光标去 statusRow 扰动 IME),停手后 flush 会刷
|
|
463
500
|
const s = status ?? { ...base, status: statusText, spinnerFrame };
|
|
464
501
|
const g = getGeo();
|
|
465
|
-
const statusRow = g.contentBottom + 1
|
|
466
|
-
// 一次写入:cup 状态行 + clearLine + 状态 + cup 回(运行态→输入框供 IME 锚定;否则续写位/内容区底)
|
|
502
|
+
const statusRow = g.contentBottom + 2; // +1 虚拟空行(内容与状态栏间隔),+2 状态行
|
|
503
|
+
// 一次写入:cup 状态行 + clearLine + 状态 + cup 回(运行态→输入框供 IME 锚定;否则续写位/内容区底)。
|
|
504
|
+
// 打字中不再跳过——单次 write 结尾 cup 回 runningCaretPos,IME 锚输入框不动;旧 isStreamingPaused 门致状态行 / 心跳打字时冻住。
|
|
467
505
|
let out = cup(statusRow, 1) + esc.clearLine + composeStatus(s, g.cols);
|
|
468
|
-
if (mode === 'running'
|
|
506
|
+
if (mode === 'running') {
|
|
507
|
+
// 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
|
|
469
508
|
const p = runningCaretPos();
|
|
470
509
|
out += cup(p.row, p.col);
|
|
471
510
|
}
|
|
@@ -504,32 +543,76 @@ function stopTurnTimer() {
|
|
|
504
543
|
turnTimer = null;
|
|
505
544
|
}
|
|
506
545
|
}
|
|
546
|
+
// ── 临时诊断:spinner frame 泄漏追踪(定位 557e678 后的间歇性泄漏后删除)──
|
|
547
|
+
// 记 paintLiveAtCursor/clearLiveAtCursor 的可疑时序:续写位漂移、clear 被守卫跳过、清错行。
|
|
548
|
+
// 同步追加到 ~/.mocode/spinner-debug.log,全 try/catch 不抛、不阻塞、不抢屏。
|
|
549
|
+
let _dbgSpinnerPath = '';
|
|
550
|
+
function dbgSpinner(msg) {
|
|
551
|
+
try {
|
|
552
|
+
if (!_dbgSpinnerPath)
|
|
553
|
+
_dbgSpinnerPath = join(homedir(), '.mocode', 'spinner-debug.log');
|
|
554
|
+
appendFileSync(_dbgSpinnerPath, `[${new Date().toISOString()}] ${msg}\n`);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// 诊断日志失败不影响渲染
|
|
558
|
+
}
|
|
559
|
+
}
|
|
507
560
|
/**
|
|
508
561
|
* 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
|
|
509
|
-
* 仅 TTY + offset=0(实时尾)
|
|
562
|
+
* 仅 TTY + offset=0(实时尾)+ 非打字暂停态时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
|
|
510
563
|
* 配合 clearLiveAtCursor 在 spinner 停时清掉,随后 contentWrite 的结果即写在该行(spinner 不入历史缓冲)。
|
|
564
|
+
*
|
|
565
|
+
* 记 frameRow/frameCol = 实际画帧位置;clearLiveAtCursor 清"这行"而非当前续写位,防续写位漂移时清错行。
|
|
566
|
+
* 续写位漂移时(运行期间被某次 contentWrite 推进,罕见但 557e678 后可能)先清旧 frameRow 再画新位,避免旧帧残留。
|
|
567
|
+
* 打字暂停态(isStreamingPaused)spinner 不画帧——恢复 557e678 前的 spinner 隐形行为(只关 spinner,不动 contentWrite)。
|
|
511
568
|
*/
|
|
512
569
|
export function paintLiveAtCursor(text) {
|
|
513
|
-
if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused())
|
|
570
|
+
if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused()) {
|
|
571
|
+
// 临时诊断:画帧被守卫跳过,但此前画过帧(frameRow)→ 该帧不会被这次 paint 覆盖,泄漏嫌疑
|
|
572
|
+
if (frameRow && scrollOffset !== 0)
|
|
573
|
+
dbgSpinner(`PAINT-SKIPPED frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) off=${scrollOffset} paused=${isStreamingPaused()}`);
|
|
514
574
|
return;
|
|
515
|
-
|
|
516
|
-
|
|
575
|
+
}
|
|
576
|
+
// 临时诊断:续写位 != 上次画帧位置 = spinner 运行期间续写位漂移(泄漏根因嫌疑)
|
|
577
|
+
if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
|
|
578
|
+
dbgSpinner(`DRIFT-PAINT old=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode} off=${scrollOffset}`);
|
|
579
|
+
}
|
|
580
|
+
let out = '';
|
|
581
|
+
if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
|
|
582
|
+
out += cup(frameRow, frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
|
|
583
|
+
}
|
|
584
|
+
out += cup(contentRow, contentCol) + esc.clearLine + text;
|
|
517
585
|
if (mode === 'running') {
|
|
518
586
|
const p = runningCaretPos();
|
|
519
587
|
out += cup(p.row, p.col);
|
|
520
588
|
}
|
|
521
589
|
stdout.write(out);
|
|
590
|
+
frameRow = contentRow;
|
|
591
|
+
frameCol = contentCol;
|
|
522
592
|
}
|
|
523
|
-
/**
|
|
593
|
+
/** 清掉 paintLiveAtCursor 画过的那行瞬时活动文本。清"实际画过的位置"(frameRow),非当前续写位。
|
|
594
|
+
* 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。 */
|
|
524
595
|
export function clearLiveAtCursor() {
|
|
525
|
-
|
|
596
|
+
// 临时诊断:clear 被守卫跳过,但此前画过帧(frameRow)→ 帧残留(泄漏嫌疑)
|
|
597
|
+
if (frameRow && (!active || !ui.isTTY || scrollOffset !== 0)) {
|
|
598
|
+
dbgSpinner(`CLEAR-SKIPPED frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) off=${scrollOffset} active=${active}`);
|
|
599
|
+
}
|
|
600
|
+
if (!active || !ui.isTTY || scrollOffset !== 0)
|
|
526
601
|
return;
|
|
527
|
-
|
|
602
|
+
if (!frameRow)
|
|
603
|
+
return; // 没画过就不清(避免误清当前续写位内容)
|
|
604
|
+
// 临时诊断:画帧位置 != 当前续写位 → 旧设计(清续写位)会清错行
|
|
605
|
+
if (frameRow !== contentRow || frameCol !== contentCol) {
|
|
606
|
+
dbgSpinner(`CLEAR-MISMATCH frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode}`);
|
|
607
|
+
}
|
|
608
|
+
let out = cup(frameRow, frameCol) + esc.clearLine; // 清"画过的行",非"当前续写位"
|
|
528
609
|
if (mode === 'running') {
|
|
529
610
|
const p = runningCaretPos();
|
|
530
611
|
out += cup(p.row, p.col);
|
|
531
612
|
}
|
|
532
613
|
stdout.write(out);
|
|
614
|
+
frameRow = 0;
|
|
615
|
+
frameCol = 0;
|
|
533
616
|
}
|
|
534
617
|
/** 更新状态行基线(模型 / context / cwd / 模式标识)。repl 在轮次边界与切模式时调。 */
|
|
535
618
|
export function setStatusBase(b) {
|
|
@@ -643,7 +726,7 @@ export function paintInput(view) {
|
|
|
643
726
|
startVis: 0,
|
|
644
727
|
}
|
|
645
728
|
: windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
|
|
646
|
-
const needFooterH =
|
|
729
|
+
const needFooterH = 4 + vis.inputRows; // 1 虚拟空 + 1 状态 + 1 上线 + 输入行 + 1 下线
|
|
647
730
|
let g = preGeo;
|
|
648
731
|
if (needFooterH !== footerH) {
|
|
649
732
|
// setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
|
|
@@ -661,15 +744,17 @@ export function paintInput(view) {
|
|
|
661
744
|
const line = slice[g.contentBottom - 1] ?? '';
|
|
662
745
|
buf += cup(g.contentBottom, 1) + esc.clearLine + line;
|
|
663
746
|
}
|
|
747
|
+
// 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行
|
|
748
|
+
buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
|
|
664
749
|
// 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
|
|
665
|
-
const statusRow = g.contentBottom + 1
|
|
750
|
+
const statusRow = g.contentBottom + 2; // +1 虚拟空行,+2 状态行
|
|
666
751
|
const status = { ...base, status: statusText, spinnerFrame };
|
|
667
752
|
buf += cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols);
|
|
668
753
|
// 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
|
|
669
|
-
buf += cup(g.contentBottom +
|
|
670
|
-
// 4. 输入行(g.contentBottom+
|
|
671
|
-
const firstInputRow = g.contentBottom +
|
|
672
|
-
const inputRowsAvail = g.footerH -
|
|
754
|
+
buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
755
|
+
// 4. 输入行(g.contentBottom+4 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
|
|
756
|
+
const firstInputRow = g.contentBottom + 4;
|
|
757
|
+
const inputRowsAvail = g.footerH - 4; // 去掉虚拟空/状态/上线/下线,留输入行
|
|
673
758
|
const indent = ' '.repeat(promptW);
|
|
674
759
|
const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
|
|
675
760
|
for (let i = 0; i < inputRowsAvail; i++) {
|
|
@@ -704,9 +789,11 @@ export function paintInput(view) {
|
|
|
704
789
|
}
|
|
705
790
|
// 6. 光标
|
|
706
791
|
if (view.dim) {
|
|
707
|
-
//
|
|
792
|
+
// 运行态(回尾 / 滚动回看均):真光标归输入框光标位(供 IME 锚定,气泡在输入框而非内容区)。
|
|
793
|
+
// 滚动态也归输入框——上滑看历史时打字,IME 候选气泡须锚到输入框(conhost IME 不跟随 cup 后续移动,
|
|
794
|
+
// 须让打字前光标已在输入框);viewport 锁历史靠 scrollOffset,不靠光标位置。
|
|
708
795
|
const p = runningCaretPos();
|
|
709
|
-
buf += cup(
|
|
796
|
+
buf += cup(p.row, p.col);
|
|
710
797
|
}
|
|
711
798
|
else {
|
|
712
799
|
const r = firstInputRow + vis.visLine;
|
|
@@ -727,8 +814,9 @@ function renderDimInputRow(prompt, text, placeholder, cols) {
|
|
|
727
814
|
const caret = `${ui.reverse} ${ui.reset}`; // 反白块状光标(1 cell,与 INPUT 态同款)
|
|
728
815
|
const contentW = Math.max(0, cols - promptW - 1);
|
|
729
816
|
if (text.length > 0) {
|
|
730
|
-
// 有打字:❯ dim + 文本(dim
|
|
731
|
-
|
|
817
|
+
// 有打字:❯ dim + 文本(dim,超长时从头部截断保留尾部——光标恒在末尾,须始终看到刚打的字,
|
|
818
|
+
// 而非 truncateDisplay 那样保留开头、把刚打的内容截没,显示成卡在开头不动的假象) + 反白光标(末尾)
|
|
819
|
+
return `${ui.dim}${prompt}${truncateDisplayHead(text, contentW)}${ui.reset}${caret}`;
|
|
732
820
|
}
|
|
733
821
|
// 空:❯ dim + 反白光标(打字起点) + dim 占位 ghost
|
|
734
822
|
const p = placeholder ? truncateDisplay(placeholder, contentW) : '';
|
|
@@ -744,11 +832,8 @@ export function paintRunningInputEcho(text, placeholder) {
|
|
|
744
832
|
if (!active || !base)
|
|
745
833
|
return;
|
|
746
834
|
const g = getGeo();
|
|
747
|
-
const inputRow = g.contentBottom +
|
|
748
|
-
|
|
749
|
-
esc.clearLine +
|
|
750
|
-
renderDimInputRow('❯ ', text, placeholder, g.cols));
|
|
751
|
-
// 同步 lastView:text 与 placeholder 拆开存,使滚动/resize 的 repaint 重画时光标位置正确(空→起点,有字→末尾)
|
|
835
|
+
const inputRow = g.contentBottom + 4; // 运行态 footerH 恒 5:虚拟空(+1)+状态(+2)+上线(+3)+输入行(+4);下线在 rows
|
|
836
|
+
// 先同步 lastView(供 runningCaretPos 算真光标位 = 新文本末尾,与假光标同位)
|
|
752
837
|
lastView = {
|
|
753
838
|
prompt: '❯ ',
|
|
754
839
|
lines: [text],
|
|
@@ -758,9 +843,14 @@ export function paintRunningInputEcho(text, placeholder) {
|
|
|
758
843
|
menu: null,
|
|
759
844
|
dim: true,
|
|
760
845
|
};
|
|
761
|
-
//
|
|
846
|
+
// 单次 write:cup 输入行 + clearLine + dim 文本/假光标 + cup 真光标到输入框(供 IME 锚定)。
|
|
847
|
+
// 滚动态也归输入框——旧设计滚动态归 contentBottom,致 IME 候选气泡锚到内容区底白块
|
|
848
|
+
// (conhost IME 不跟随 cup 后续移动,须让打字前光标已在输入框);拆两次 write 会暂留 contentBottom 显白块。
|
|
762
849
|
const p = runningCaretPos();
|
|
763
|
-
stdout.write(cup(
|
|
850
|
+
stdout.write(cup(inputRow, 1) +
|
|
851
|
+
esc.clearLine +
|
|
852
|
+
renderDimInputRow('❯ ', text, placeholder, g.cols) +
|
|
853
|
+
cup(p.row, p.col));
|
|
764
854
|
}
|
|
765
855
|
/** 重画当前视图(resize / 内部用)。 */
|
|
766
856
|
export function repaint() {
|
|
@@ -779,6 +869,9 @@ export function enterInputMode(status = '空闲') {
|
|
|
779
869
|
runningFrame = -1; // 回 INPUT 态:停状态行 chip 旋转,composeStatus 退回静态 ◆
|
|
780
870
|
turnStart = null; // 停走时
|
|
781
871
|
stopTurnTimer();
|
|
872
|
+
scrollLockUntil = 0; // 轮末:清轮首滚动锁,INPUT 态可自由滚动
|
|
873
|
+
frameRow = 0; // 轮末:清 spinner 帧位置(防下轮残留)
|
|
874
|
+
frameCol = 0;
|
|
782
875
|
// 运行态若有未 flush 的缓冲内容(用户打字暂停了流式写),切回 INPUT 前重画内容区显示之,免丢内容
|
|
783
876
|
if (flushTimer) {
|
|
784
877
|
clearTimeout(flushTimer);
|
|
@@ -790,7 +883,7 @@ export function enterInputMode(status = '空闲') {
|
|
|
790
883
|
repaintViewport();
|
|
791
884
|
}
|
|
792
885
|
if (active && base) {
|
|
793
|
-
setRegion(
|
|
886
|
+
setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
|
|
794
887
|
paintInput({
|
|
795
888
|
prompt: '❯ ',
|
|
796
889
|
lines: [''],
|
|
@@ -801,15 +894,16 @@ export function enterInputMode(status = '空闲') {
|
|
|
801
894
|
stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
|
|
802
895
|
}
|
|
803
896
|
}
|
|
804
|
-
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒
|
|
897
|
+
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 5(虚拟空+状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
|
|
805
898
|
export function enterRunningMode(status, placeholder) {
|
|
806
899
|
mode = 'running';
|
|
807
900
|
statusText = status;
|
|
808
901
|
spinnerFrame = undefined;
|
|
809
902
|
turnStart = Date.now(); // 起走时(整轮从发起到 enterInputMode 止)
|
|
810
903
|
resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
|
|
904
|
+
lockScrollToBottom(); // 轮首短时锁:吸收发消息前后残留滚轮事件,保 agent 输出从底部开始(锁过期或轮末 enterInputMode 解)
|
|
811
905
|
if (active && base) {
|
|
812
|
-
setRegion(
|
|
906
|
+
setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
|
|
813
907
|
paintInput({
|
|
814
908
|
prompt: '❯ ',
|
|
815
909
|
lines: [''],
|
|
@@ -833,11 +927,12 @@ export function enterAltScreen() {
|
|
|
833
927
|
active = true;
|
|
834
928
|
stdout.write(esc.altOn);
|
|
835
929
|
stdout.write(esc.mouseOn); // SGR 鼠标追踪:滚轮发 \x1B[<btn;col;rowM 报表,经 mouse.consumeMouse 重组 → scrollBy(滚轮滚动靠此 + onKey/onRunningKey 顶部守卫)
|
|
836
|
-
setRegion(
|
|
930
|
+
setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线(底栏始终含上下线)
|
|
837
931
|
contentRow = 1;
|
|
838
932
|
contentCol = 1;
|
|
839
933
|
segmentStartRow = 1;
|
|
840
934
|
scrollOffset = 0;
|
|
935
|
+
scrollLockUntil = 0;
|
|
841
936
|
mdActive = false;
|
|
842
937
|
mdBuf = '';
|
|
843
938
|
content.reset();
|
|
@@ -874,6 +969,9 @@ export function exitAltScreen() {
|
|
|
874
969
|
active = false;
|
|
875
970
|
stopTurnTimer(); // 兜底清走时计时器(防异常退出泄漏)
|
|
876
971
|
turnStart = null;
|
|
972
|
+
scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
|
|
973
|
+
frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
|
|
974
|
+
frameCol = 0;
|
|
877
975
|
mouse.resetMouse(); // 清鼠标重组残留(防退出后状态泄漏到下次进 alt 屏)
|
|
878
976
|
// raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
|
|
879
977
|
try {
|