mocode-ai 1.2.1 → 1.2.3
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/dist/agent/core.js +14 -3
- package/dist/agent/spawn.js +1 -0
- package/dist/rollback/index.js +167 -24
- package/dist/tools/registry.js +4 -2
- package/dist/ui/intervention.js +2 -2
- package/dist/ui/layout.js +13 -12
- package/package.json +1 -1
package/dist/agent/core.js
CHANGED
|
@@ -337,9 +337,20 @@ export async function runAgentCore(opts) {
|
|
|
337
337
|
const modelStartedAt = Date.now();
|
|
338
338
|
const provider = safeProviderId(requestBaseURL);
|
|
339
339
|
emitTrace('model_start', { model: requestModel, provider });
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
340
|
+
// 动态注入(仅追加到 system 末尾,不触碰 staticBody 前缀 → 不破坏 prompt 缓存):
|
|
341
|
+
// - 开场分析:仅主线(step===0 且 !suppressOpeningAnalysis)注入——即"用户发一个任务后,
|
|
342
|
+
// agent 第一次模型调用"。子代理经 spawn.ts 传 suppressOpeningAnalysis:true 排除。
|
|
343
|
+
// - historyRebuilt:compact 恢复步;要求重新锚定目标。两者可叠加但互不串扰。
|
|
344
|
+
// .filter(Boolean) 保证空段不产生多余空行;后续 step 均为空串 → system 前缀稳定命中缓存。
|
|
345
|
+
// 安全保证:此段只拼进 requestHistory(新建对象),绝不回写 history[0],故不会跨 step/跨 turn 残留。
|
|
346
|
+
const dynamicSystemSuffix = [
|
|
347
|
+
(!opts.suppressOpeningAnalysis && step === 0)
|
|
348
|
+
? '## Opening analysis\nBegin your FIRST response of this turn with a brief analysis of the request and your planned approach (1-3 sentences, no filler), THEN start tool calls. This opening is the only place where pre-tool prose is expected; after it, work quietly with no narration between tool calls.'
|
|
349
|
+
: '',
|
|
350
|
+
historyRebuilt
|
|
351
|
+
? '## Post-compaction recovery\nContext was compacted before this request. Re-establish the current objective and unresolved work from retained evidence or the session note, avoid repeating completed investigation, and re-read exact file context before any dependent edit.'
|
|
352
|
+
: '',
|
|
353
|
+
].filter(Boolean).join('\n\n');
|
|
343
354
|
const systemMessage = history[0];
|
|
344
355
|
const requestHistory = dynamicSystemSuffix
|
|
345
356
|
&& systemMessage?.role === 'system'
|
package/dist/agent/spawn.js
CHANGED
|
@@ -225,6 +225,7 @@ export async function spawnAgent(opts) {
|
|
|
225
225
|
maxSteps,
|
|
226
226
|
toolsOverride,
|
|
227
227
|
contextState: localContextState,
|
|
228
|
+
suppressOpeningAnalysis: true, // 子代理不注入「开场分析」:仅主线面对用户的首次响应用
|
|
228
229
|
onToolOutcome: (tool, args) => {
|
|
229
230
|
if (tool === 'read_file' && typeof args.path === 'string')
|
|
230
231
|
readSet.add(args.path);
|
package/dist/rollback/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync,
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import * as fsp from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { config } from '../config/index.js';
|
|
5
6
|
import { truncateDisplay } from '../ui/render.js';
|
|
@@ -50,8 +51,19 @@ function readState(full) {
|
|
|
50
51
|
}
|
|
51
52
|
return { kind: 'missing' };
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* 状态等价判定。两侧都捕获了内容时按内容比(与旧行为一致,精确);
|
|
56
|
+
* 任一侧内容未捕获(超预算的大文件)时退化为 stamp 比较——size+mtimeNs 变了就算变。
|
|
57
|
+
*/
|
|
53
58
|
function sameState(a, b) {
|
|
54
|
-
|
|
59
|
+
if (a.kind !== b.kind || a.mode !== b.mode)
|
|
60
|
+
return false;
|
|
61
|
+
if (a.data !== undefined && b.data !== undefined)
|
|
62
|
+
return a.data === b.data;
|
|
63
|
+
if (a.data === undefined && b.data === undefined && a.stamp === undefined && b.stamp === undefined) {
|
|
64
|
+
return true; // directory / missing:kind+mode 已足够
|
|
65
|
+
}
|
|
66
|
+
return a.stamp === b.stamp;
|
|
55
67
|
}
|
|
56
68
|
function stateFingerprint(state) {
|
|
57
69
|
return createHash('sha256')
|
|
@@ -82,6 +94,8 @@ function snapshotFromState(rel, state, sequence, op, createdParents = [], after)
|
|
|
82
94
|
ops: [op],
|
|
83
95
|
createdParents: createdParents.length > 0 ? createdParents : undefined,
|
|
84
96
|
afterFingerprint: after ? stateFingerprint(after) : undefined,
|
|
97
|
+
// 文件但没有内容 = 工作区扫描时超出捕获预算,只能报告"变了",不能拿它覆盖磁盘。
|
|
98
|
+
contentUnavailable: state.kind === 'file' && state.data === undefined ? true : undefined,
|
|
85
99
|
};
|
|
86
100
|
}
|
|
87
101
|
/** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
|
|
@@ -163,55 +177,172 @@ export function endPathMutation(capture, op) {
|
|
|
163
177
|
if (changed)
|
|
164
178
|
mutationVersion += 1;
|
|
165
179
|
}
|
|
166
|
-
// 构建产物 /
|
|
167
|
-
//
|
|
168
|
-
//
|
|
180
|
+
// 构建产物 / 依赖树 / 缓存 / 运行时状态目录:可再生,扫描它们既昂贵也易把后台 daemon、
|
|
181
|
+
// 打包器、mocode 自身(会话日志 / dev-server 日志 / 截图)的写入误判成模型改动。
|
|
182
|
+
// 回滚本就只应覆盖源码。
|
|
169
183
|
const EXCLUDED_WORKSPACE_DIRS = new Set([
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
'.
|
|
184
|
+
// VCS / 索引 / mocode 自身运行时状态(会话、trace、dev-server 日志、记忆、截图每轮都在写,
|
|
185
|
+
// 既无回滚意义,又会被误判成模型改动)
|
|
186
|
+
'.git', '.hg', '.svn', '.codegraph', '.mocode',
|
|
187
|
+
// 依赖树与包管理器缓存
|
|
188
|
+
'node_modules', 'vendor', 'bower_components', '.yarn', '.pnpm-store', '.venv', 'venv', 'pods',
|
|
189
|
+
// 构建产物
|
|
190
|
+
'dist', 'build', 'out', 'target', 'coverage', '.output', '.next', '.nuxt', '.vite',
|
|
191
|
+
'.turbo', '.svelte-kit', '.angular', '.astro', '.docusaurus', '.dart_tool', '.terraform',
|
|
192
|
+
// 临时与缓存
|
|
193
|
+
'.tmp', 'tmp', '.cache', '.parcel-cache', '.nyc_output', '__pycache__',
|
|
194
|
+
'.pytest_cache', '.mypy_cache', '.ruff_cache', '.gradle',
|
|
173
195
|
]);
|
|
196
|
+
/** 单文件内容捕获上限:更大的文件只留 stamp(可检测变化,不可恢复),避免把巨型二进制读进内存。 */
|
|
197
|
+
const CAPTURE_FILE_LIMIT = 1024 * 1024;
|
|
198
|
+
/** 单次扫描的内容总预算:超出后剩余文件只留 stamp。 */
|
|
199
|
+
const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
|
|
200
|
+
/** 条目上限:超大工作区不做无边界遍历(超出部分不参与变更检测)。 */
|
|
201
|
+
const CAPTURE_ENTRY_LIMIT = 20000;
|
|
202
|
+
/** 并发文件操作数:冷缓存(尤其 Windows 杀软逐文件扫描)下 I/O 重叠远快于串行。 */
|
|
203
|
+
const SCAN_CONCURRENCY = 16;
|
|
204
|
+
/** 让出事件循环的节奏:每 N 个条目,或每累计编码 M 字节(base64 是纯 CPU,大文件靠字节数兜底)。 */
|
|
205
|
+
const YIELD_EVERY = 64;
|
|
206
|
+
const YIELD_BYTES = 1024 * 1024;
|
|
207
|
+
/**
|
|
208
|
+
* 刚被写过的文件不信缓存,强制重读内容。
|
|
209
|
+
* 原因:stamp 依赖 mtime 精度。NTFS/ext4/APFS 是 100ns~ns 级,但 exFAT / 部分网络盘只有 1~2s,
|
|
210
|
+
* 那里一条"同尺寸原地改写"可能与快照前共享同一时间戳,只比 stamp 会漏掉真实变化。
|
|
211
|
+
* 只对最近 2s 内改动的文件付重读代价(通常正是命令刚碰过的那几个),开销可忽略。
|
|
212
|
+
*/
|
|
213
|
+
const FRESH_WINDOW_MS = 2000;
|
|
214
|
+
/** path → 上次扫描捕获的内容。stamp(size+mtimeNs)未变即复用,未改动的文件不重复读盘。 */
|
|
215
|
+
let contentCache = new Map();
|
|
216
|
+
let cachedSessionDir = '';
|
|
217
|
+
let resolvedSessionDir = '';
|
|
218
|
+
function sessionDirAbs() {
|
|
219
|
+
if (config.sessionDir !== cachedSessionDir) {
|
|
220
|
+
cachedSessionDir = config.sessionDir;
|
|
221
|
+
resolvedSessionDir = path.resolve(config.sessionDir);
|
|
222
|
+
}
|
|
223
|
+
return resolvedSessionDir;
|
|
224
|
+
}
|
|
174
225
|
function isWorkspaceExcluded(full) {
|
|
175
226
|
const base = path.basename(full).toLowerCase();
|
|
176
227
|
// Git 元数据必须永久排除以保护 index;依赖树/代码索引/构建产物/临时目录是可再生运行时状态。
|
|
177
228
|
if (EXCLUDED_WORKSPACE_DIRS.has(base))
|
|
178
229
|
return true;
|
|
179
|
-
|
|
180
|
-
return isInside(sessionDir, full);
|
|
230
|
+
return isInside(sessionDirAbs(), full);
|
|
181
231
|
}
|
|
182
|
-
|
|
232
|
+
const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
233
|
+
/**
|
|
234
|
+
* 工作区快照(run_command / dev_server / MCP 等不透明工具用)。
|
|
235
|
+
*
|
|
236
|
+
* **必须是异步且带缓存的**:本函数在每次这类工具调用前后各跑一次,直接坐在用户交互
|
|
237
|
+
* 路径上。旧实现用 readdirSync + 全量 readFileSync(base64) 同步遍历整棵工作树 ——
|
|
238
|
+
* 冷文件缓存下 400 个文件实测就要 ~3s(Windows),几千个文件即数十秒,期间事件循环
|
|
239
|
+
* 完全阻塞:spinner 冻结、走时停摆、键鼠无响应,用户看到的就是「卡在 执行 run_command」。
|
|
240
|
+
*
|
|
241
|
+
* 现在:
|
|
242
|
+
* 1) fs/promises + 有界并发 + 定期 setImmediate 让出 → 事件循环全程可呼吸;
|
|
243
|
+
* 2) 内容按 (size, mtimeNs, mode) 缓存复用 → 未改动的文件不再重复读盘,
|
|
244
|
+
* 一次会话里只有首次扫描付全量代价,之后只读真正变化的文件;
|
|
245
|
+
* 3) 单文件 / 总量 / 条目数三重预算 → 巨型仓库不会把内存和时间吃穿。
|
|
246
|
+
*/
|
|
247
|
+
async function scanWorkspace() {
|
|
183
248
|
const entries = new Map();
|
|
184
|
-
const
|
|
249
|
+
const nextCache = new Map();
|
|
250
|
+
const paths = [];
|
|
251
|
+
// 第一趟:只 readdir 收集条目(廉价,不读内容)。目录本身即刻记账,顺序稳定。
|
|
252
|
+
const walk = async (dir) => {
|
|
253
|
+
if (paths.length >= CAPTURE_ENTRY_LIMIT)
|
|
254
|
+
return;
|
|
185
255
|
let children;
|
|
186
256
|
try {
|
|
187
|
-
children =
|
|
257
|
+
children = await fsp.readdir(dir, { withFileTypes: true });
|
|
188
258
|
}
|
|
189
259
|
catch {
|
|
190
260
|
return;
|
|
191
261
|
}
|
|
192
262
|
for (const child of children) {
|
|
263
|
+
if (paths.length >= CAPTURE_ENTRY_LIMIT)
|
|
264
|
+
return;
|
|
193
265
|
const full = path.join(dir, child.name);
|
|
194
266
|
if (isWorkspaceExcluded(full))
|
|
195
267
|
continue;
|
|
196
|
-
|
|
268
|
+
paths.push(full);
|
|
269
|
+
// isDirectory() 对 symlink 为 false —— 与旧实现一致:不跟随软链。
|
|
270
|
+
if (child.isDirectory())
|
|
271
|
+
await walk(full);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
await walk(rootDir());
|
|
275
|
+
// 第二趟:有界并发读状态。budget 由完成顺序消费——两次扫描间某个大文件是否被捕获
|
|
276
|
+
// 可能不同,sameState 在任一侧缺内容时退化为 stamp 比较,故不会产生伪变化。
|
|
277
|
+
let budget = CAPTURE_TOTAL_LIMIT;
|
|
278
|
+
let cursor = 0;
|
|
279
|
+
let processed = 0;
|
|
280
|
+
let bytesSinceYield = 0;
|
|
281
|
+
const worker = async () => {
|
|
282
|
+
while (cursor < paths.length) {
|
|
283
|
+
const full = paths[cursor++];
|
|
284
|
+
if (++processed % YIELD_EVERY === 0 || bytesSinceYield >= YIELD_BYTES) {
|
|
285
|
+
bytesSinceYield = 0;
|
|
286
|
+
await yieldToEventLoop();
|
|
287
|
+
}
|
|
288
|
+
let state;
|
|
289
|
+
try {
|
|
290
|
+
const stat = await fsp.lstat(full, { bigint: true });
|
|
291
|
+
const mode = Number(stat.mode) & 0o777;
|
|
292
|
+
if (stat.isSymbolicLink()) {
|
|
293
|
+
const target = await fsp.readlink(full);
|
|
294
|
+
state = { kind: 'symlink', data: target, stamp: target, mode };
|
|
295
|
+
}
|
|
296
|
+
else if (stat.isDirectory()) {
|
|
297
|
+
state = { kind: 'directory', mode, stamp: '' };
|
|
298
|
+
}
|
|
299
|
+
else if (stat.isFile()) {
|
|
300
|
+
const size = Number(stat.size);
|
|
301
|
+
const stamp = `${size}:${stat.mtimeNs}`;
|
|
302
|
+
// 未来时间戳(时钟偏移)同样按"新鲜"处理 —— 宁可多读一次,不可漏判变化。
|
|
303
|
+
const fresh = Date.now() - Number(stat.mtimeNs / 1000000n) < FRESH_WINDOW_MS;
|
|
304
|
+
const cached = fresh ? undefined : contentCache.get(full);
|
|
305
|
+
if (cached && cached.stamp === stamp && cached.mode === mode) {
|
|
306
|
+
budget -= size; // 命中也计预算,保证冷/热缓存下的捕获集合一致
|
|
307
|
+
state = { kind: 'file', data: cached.data, stamp, mode };
|
|
308
|
+
nextCache.set(full, cached);
|
|
309
|
+
}
|
|
310
|
+
else if (size <= CAPTURE_FILE_LIMIT && budget - size >= 0) {
|
|
311
|
+
budget -= size;
|
|
312
|
+
bytesSinceYield += size;
|
|
313
|
+
const data = (await fsp.readFile(full)).toString('base64');
|
|
314
|
+
state = { kind: 'file', data, stamp, mode };
|
|
315
|
+
nextCache.set(full, { stamp, mode, data });
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
// 超大文件 / 预算耗尽:只留指纹,能报告变化但不参与内容恢复。
|
|
319
|
+
state = { kind: 'file', stamp, mode };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
state = { kind: 'missing' };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
state = { kind: 'missing' }; // 不存在或不可读:工具若最终也不可读,不会产生伪变化
|
|
328
|
+
}
|
|
197
329
|
if (state.kind === 'missing')
|
|
198
330
|
continue;
|
|
199
|
-
|
|
200
|
-
entries.set(rel, state);
|
|
201
|
-
if (state.kind === 'directory')
|
|
202
|
-
walk(full);
|
|
331
|
+
entries.set(toRel(full), state);
|
|
203
332
|
}
|
|
204
333
|
};
|
|
205
|
-
|
|
334
|
+
await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
|
|
335
|
+
// 缓存整体替换:已删除的文件自然被淘汰,内存上限 ≈ CAPTURE_TOTAL_LIMIT。
|
|
336
|
+
contentCache = nextCache;
|
|
206
337
|
return entries;
|
|
207
338
|
}
|
|
208
339
|
/** run_command/MCP 前调用。不跟随 symlink,并排除 .git、会话快照、依赖树与代码索引。 */
|
|
209
|
-
export function beginWorkspaceMutation() {
|
|
210
|
-
return { sequence: ++sequenceCounter, entries: scanWorkspace() };
|
|
340
|
+
export async function beginWorkspaceMutation() {
|
|
341
|
+
return { sequence: ++sequenceCounter, entries: await scanWorkspace() };
|
|
211
342
|
}
|
|
212
343
|
/** 不透明工具执行后比较整个工作区,把实际变化压入当前轮事务日志。 */
|
|
213
|
-
export function endWorkspaceMutation(capture, op) {
|
|
214
|
-
const after = scanWorkspace();
|
|
344
|
+
export async function endWorkspaceMutation(capture, op) {
|
|
345
|
+
const after = await scanWorkspace();
|
|
215
346
|
const paths = new Set([...capture.entries.keys(), ...after.keys()]);
|
|
216
347
|
let changed = false;
|
|
217
348
|
for (const rel of paths) {
|
|
@@ -234,10 +365,16 @@ export function getCurrentTurnMutationState() {
|
|
|
234
365
|
continue;
|
|
235
366
|
let change = byPath.get(snapshot.path);
|
|
236
367
|
if (!change) {
|
|
237
|
-
change = {
|
|
368
|
+
change = {
|
|
369
|
+
path: snapshot.path,
|
|
370
|
+
ops: [],
|
|
371
|
+
snapshotAvailable: snapshot.contentUnavailable !== true,
|
|
372
|
+
};
|
|
238
373
|
byPath.set(snapshot.path, change);
|
|
239
374
|
order.push(snapshot.path);
|
|
240
375
|
}
|
|
376
|
+
if (snapshot.contentUnavailable)
|
|
377
|
+
change.snapshotAvailable = false;
|
|
241
378
|
for (const op of snapshot.ops ?? ['file_change']) {
|
|
242
379
|
if (!change.ops.includes(op))
|
|
243
380
|
change.ops.push(op);
|
|
@@ -281,6 +418,8 @@ export function planRollback(n, history) {
|
|
|
281
418
|
if (snapshot.turnId <= cutoffTurnId)
|
|
282
419
|
continue;
|
|
283
420
|
const change = ensure(snapshot.path);
|
|
421
|
+
if (snapshot.contentUnavailable)
|
|
422
|
+
change.snapshotAvailable = false;
|
|
284
423
|
for (const op of snapshot.ops ?? ['file_change']) {
|
|
285
424
|
if (!change.ops.includes(op))
|
|
286
425
|
change.ops.push(op);
|
|
@@ -300,6 +439,9 @@ function restoreSnapshot(snapshot) {
|
|
|
300
439
|
const full = safeFullPath(snapshot.path);
|
|
301
440
|
if (!full)
|
|
302
441
|
return false;
|
|
442
|
+
// 内容未捕获(工作区扫描时超出预算):宁可报冲突,也不能拿空内容覆盖用户文件。
|
|
443
|
+
if (snapshot.contentUnavailable)
|
|
444
|
+
return false;
|
|
303
445
|
const state = stateFromSnapshot(snapshot);
|
|
304
446
|
try {
|
|
305
447
|
if (state.kind === 'missing') {
|
|
@@ -413,6 +555,7 @@ export function resetState() {
|
|
|
413
555
|
turnIdCounter = 0;
|
|
414
556
|
currentTurnId = 0;
|
|
415
557
|
sequenceCounter = 0;
|
|
558
|
+
contentCache = new Map();
|
|
416
559
|
}
|
|
417
560
|
export function rebuildFromHistory(history) {
|
|
418
561
|
const rebuilt = [];
|
package/dist/tools/registry.js
CHANGED
|
@@ -148,8 +148,10 @@ async function executeToolOnce(tool, args, signal, opts) {
|
|
|
148
148
|
? beginPathMutation(args.path)
|
|
149
149
|
: null;
|
|
150
150
|
capturedPath = pathCapture?.path;
|
|
151
|
+
// 工作区快照是异步的:它遍历整棵工作树,同步实现会在每次 run_command/MCP 调用前后
|
|
152
|
+
// 阻塞事件循环数秒(TUI 完全冻结)。await 让 spinner / 走时 / 键鼠在扫描期间继续工作。
|
|
151
153
|
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
152
|
-
? beginWorkspaceMutation()
|
|
154
|
+
? await beginWorkspaceMutation()
|
|
153
155
|
: null;
|
|
154
156
|
let raw;
|
|
155
157
|
try {
|
|
@@ -159,7 +161,7 @@ async function executeToolOnce(tool, args, signal, opts) {
|
|
|
159
161
|
if (pathCapture)
|
|
160
162
|
endPathMutation(pathCapture, tool.name);
|
|
161
163
|
if (workspaceCapture)
|
|
162
|
-
endWorkspaceMutation(workspaceCapture, tool.name);
|
|
164
|
+
await endWorkspaceMutation(workspaceCapture, tool.name);
|
|
163
165
|
}
|
|
164
166
|
const mutationAfter = getCurrentTurnMutationState();
|
|
165
167
|
const changedFiles = mutationAfter.version !== mutationBefore.version
|
package/dist/ui/intervention.js
CHANGED
|
@@ -210,7 +210,7 @@ export async function promptIntervention(req) {
|
|
|
210
210
|
layout.resetScroll();
|
|
211
211
|
else
|
|
212
212
|
layout.repaintViewport();
|
|
213
|
-
// 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行
|
|
213
|
+
// 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 80ms 刷新。
|
|
214
214
|
layout.startTurnTimerIfRunning();
|
|
215
215
|
}
|
|
216
216
|
function onKey(_str, key) {
|
|
@@ -339,7 +339,7 @@ export async function promptIntervention(req) {
|
|
|
339
339
|
return new Promise((res, rej) => {
|
|
340
340
|
resolve = res;
|
|
341
341
|
try {
|
|
342
|
-
// 进入面板:停 spinner(避免 onFrame 覆盖)+ 停走时计时器(避免 drawStatusBar
|
|
342
|
+
// 进入面板:停 spinner(避免 onFrame 覆盖)+ 停走时计时器(避免 drawStatusBar 80ms 心跳
|
|
343
343
|
// 把真光标拉到 runningCaretPos 覆盖 paintInput 的正确光标位)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
|
|
344
344
|
Spinner.pauseCurrent();
|
|
345
345
|
layout.stopTurnTimer();
|
package/dist/ui/layout.js
CHANGED
|
@@ -82,7 +82,7 @@ const SCROLL_LOCK_MS = 400; // 锁时长:覆盖 OS 缓冲残留 + 常规滚轮
|
|
|
82
82
|
let base = null;
|
|
83
83
|
// 运行态实时 token 用量(agent core 流式推送,轮末 repl 清 undefined)。
|
|
84
84
|
// composeModelLine 在 RUNNING 态把它画成 chip 放 context 进度条左侧;
|
|
85
|
-
// 不主动触发重画——RUNNING 态 turnTimer
|
|
85
|
+
// 不主动触发重画——RUNNING 态 turnTimer 80ms 心跳重画自然取最新值。
|
|
86
86
|
let liveUsage;
|
|
87
87
|
let statusText = '';
|
|
88
88
|
let spinnerFrame;
|
|
@@ -92,8 +92,8 @@ let turnTimer = null; // 走时刷新计时器(独立于 spinner):流式期间 s
|
|
|
92
92
|
* 驱动 paintInput setRegion(fh) 动态撑高脚栏;drawStatusBar 据此画 1/2 行 plan,
|
|
93
93
|
* 与 spinner/上线/输入/下线/model 行的 +1/+2/+3/+4/+5 偏移天然一致(contentBottom 自动重算)。 */
|
|
94
94
|
let planRows = 1;
|
|
95
|
-
let runningFrame = -1; // 运行态状态行 chip 心跳帧(
|
|
96
|
-
const RUNNING_FRAMES = ['
|
|
95
|
+
let runningFrame = -1; // 运行态状态行 chip 心跳帧(转圈帧 明灭);INPUT 态 -1 退回静态 ●
|
|
96
|
+
const RUNNING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
97
97
|
// 运行态用户打字时暂停流式物理写:流式每个 token 要 cup 到 contentRow 写入,IME 候选窗逐光标移动跟踪会跟过去;
|
|
98
98
|
// 用户打字期间只喂缓冲、不物理写,光标留输入框;停手 USER_ACTIVE_PAUSE_MS 后 flush 重画缓冲内容。
|
|
99
99
|
let userActiveUntil = 0; // 打字活跃截止时刻(Date.now()+PAUSE);0=未活跃
|
|
@@ -1332,7 +1332,7 @@ function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
|
|
|
1332
1332
|
* 示例:
|
|
1333
1333
|
* INPUT: ● 空闲
|
|
1334
1334
|
* 思考中: ⠹ 思考中… 0.5s
|
|
1335
|
-
* 运行心跳:
|
|
1335
|
+
* 运行心跳: ⠋ 生成中 0.5s
|
|
1336
1336
|
* 滚动回看: ● 空闲 历史 ↑3 (PgDn 回底) */
|
|
1337
1337
|
function composeSpinnerLine(status, cols) {
|
|
1338
1338
|
const spinning = mode === 'running' && runningFrame >= 0;
|
|
@@ -1361,10 +1361,11 @@ function composeSpinnerLine(status, cols) {
|
|
|
1361
1361
|
leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
|
|
1362
1362
|
}
|
|
1363
1363
|
else if (spinning) {
|
|
1364
|
-
// 运行态心跳帧(流式输出中):帧 + 走时
|
|
1364
|
+
// 运行态心跳帧(流式输出中):帧 + 生成中 + 走时
|
|
1365
|
+
const label = '生成中';
|
|
1365
1366
|
const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
|
|
1366
|
-
lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset}${ePart}`;
|
|
1367
|
-
leadW = 1 + (elapsed ? 1 + displayWidth(elapsed) : 0);
|
|
1367
|
+
lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset} ${ui.dim}${label}${ui.reset}${ePart}`;
|
|
1368
|
+
leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
|
|
1368
1369
|
}
|
|
1369
1370
|
else {
|
|
1370
1371
|
// INPUT 态:● + 状态文字(无走时)
|
|
@@ -1539,9 +1540,9 @@ export function setStatus(status, frame) {
|
|
|
1539
1540
|
drawStatusBar();
|
|
1540
1541
|
}
|
|
1541
1542
|
/**
|
|
1542
|
-
* 启走时刷新计时器:RUNNING 态每
|
|
1543
|
+
* 启走时刷新计时器:RUNNING 态每 80ms 重画状态行,使 composeStatus 重算 elapsed。
|
|
1543
1544
|
* 必要性:spinner 在首 token 到达即 stop,思考/正文流式期间状态行不再经 spinner 刷新;
|
|
1544
|
-
* 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms
|
|
1545
|
+
* 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 同速,重叠幂等无妨。
|
|
1545
1546
|
* 非 TTY 不启(active=false 时 drawStatusBar 为 no-op)。
|
|
1546
1547
|
*/
|
|
1547
1548
|
function startTurnTimer() {
|
|
@@ -1552,10 +1553,10 @@ function startTurnTimer() {
|
|
|
1552
1553
|
turnTimer = setInterval(() => {
|
|
1553
1554
|
runningFrame = (runningFrame + 1) % RUNNING_FRAMES.length; // 推进心跳帧,让前导符跳动
|
|
1554
1555
|
drawStatusBar();
|
|
1555
|
-
},
|
|
1556
|
+
}, 80);
|
|
1556
1557
|
turnTimer.unref();
|
|
1557
1558
|
}
|
|
1558
|
-
/** 停走时计时器。enterInputMode / exitAltScreen 调;intervention 面板进入时也调(防 drawStatusBar
|
|
1559
|
+
/** 停走时计时器。enterInputMode / exitAltScreen 调;intervention 面板进入时也调(防 drawStatusBar 80ms 心跳把光标拉到 runningCaretPos,覆盖 paintInput 的正确光标位)。 */
|
|
1559
1560
|
export function stopTurnTimer() {
|
|
1560
1561
|
if (turnTimer) {
|
|
1561
1562
|
clearInterval(turnTimer);
|
|
@@ -1646,7 +1647,7 @@ export function clearLiveAtCursor() {
|
|
|
1646
1647
|
frameCol = 0;
|
|
1647
1648
|
}
|
|
1648
1649
|
/** 推送 / 清空运行态实时 token 用量(agent core 流式推送;repl 轮末清 undefined)。
|
|
1649
|
-
* 不触发重画:RUNNING 态 turnTimer
|
|
1650
|
+
* 不触发重画:RUNNING 态 turnTimer 80ms 心跳重画状态行,自然取到最新值。 */
|
|
1650
1651
|
export function setLiveUsage(u) {
|
|
1651
1652
|
liveUsage = u;
|
|
1652
1653
|
}
|