mocode-ai 1.3.1 → 1.3.2
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 +1 -0
- package/README.zh-CN.md +1 -0
- package/dist/i18n/index.js +16 -4
- package/dist/repl/index.js +26 -5
- package/dist/session/query-history.js +45 -0
- package/dist/ui/batch.js +62 -11
- package/dist/ui/clipboard.js +24 -6
- package/dist/ui/composer.js +785 -0
- package/dist/ui/fuzzy.js +81 -0
- package/dist/ui/history-picker.js +214 -0
- package/dist/ui/layout.js +24 -2
- package/dist/ui/prompt.js +137 -3
- package/dist/ui/theme.js +7 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ MoCode isn't a chat box with a coat of paint — it's an agent that actually get
|
|
|
82
82
|
- **Project context (`AGENTS.md`)** — A single project-level memory file at `AGENTS.md` captures both static facts (project description, commands, module list, directory tree) and human/AI-written insights (conventions, architectural decisions, pitfalls). Generate it once with `/init`, then keep it up to date by hand or by asking the agent to refresh it. Loaded automatically into the system prompt on every turn.
|
|
83
83
|
- **Session notepad (notes.md)** — For complex multi-step tasks (≥3 file changes / ≥5 tool calls), the agent maintains a working notepad at `.mocode/sessions/<sessionId>/notes.md` (file-based, survives context compression). It records the execution plan with the dedicated `plan_update` tool — a three-state step machine (`pending`/`in_progress`/`completed`, at most one `in_progress`) that auto-settles to `## Done:` when finished. The active plan is re-injected into the system prompt after compaction and re-synced into context whenever notes.md changes, and a gentle reminder nudges the agent if it goes several tool-steps without updating the plan. A live progress chip in the TUI status bar shows `plan: [title] (3/7) ▸ [current step]`.
|
|
84
84
|
- **Interruptible and reversible** — Ctrl+C interrupts the current turn at any time (kills child processes recursively, rolls history back to before the turn started, leaves no half-finished tool calls). `/rollback` restores file changes from per-turn snapshots, with a per-file keep/undo choice — no git dependency required.
|
|
85
|
+
- **Input safety net** — Long prompts no longer fear a stray Enter: `Ctrl+G` opens an in-TUI composer popup (notepad-style editing — Enter inserts a newline, with soft wrap, selection, copy/cut/paste and undo; Ctrl+S fills the text back into the input box without sending). `Ctrl+R`/`Ctrl+P` fuzzy-search your input history (Enter only fills it back), and the post-send recall window widens to 2 seconds with any-key recall for long inputs.
|
|
85
86
|
- **Sandbox protection** — File reads/writes go through a sandbox that blocks out-of-bounds paths (`../../`, absolute paths outside the root, symlink escapes, etc.), so the agent never touches files outside your working directory.
|
|
86
87
|
|
|
87
88
|
## Features
|
package/README.zh-CN.md
CHANGED
|
@@ -81,6 +81,7 @@ mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
|
|
|
81
81
|
- **跨会话长期记忆** — agent 能把项目架构、约定、踩过的坑存成长期记忆,下次会话自动加载;后台还会定期从对话里反思挖掘值得记住的事。记忆可增删改、带召回衰减。
|
|
82
82
|
- **会话记事本(notes.md)** — 复杂多步任务(≥3 处文件改动 / ≥5 步工具调用)时,agent 在 `.mocode/sessions/<sessionId>/notes.md` 维护一个工作记事本(落盘抗压缩),可记录中间发现、设计决策、待验证问题和结构化计划。执行计划由专用 `plan_update` 工具维护——三态步骤机(`pending`/`in_progress`/`completed`,同一时刻至多一个 `in_progress`),全部完成自动结算为 `## Done:`。活跃 plan 在压缩后重注入系统提示、notes.md 一变就重同步进上下文,若连续多步未更新还会有温和提醒。TUI 状态栏实时显示进度 chip:`plan: [标题] (3/7) ▸ [当前步]`。
|
|
83
83
|
- **可中断、可回滚** — Ctrl+C 随时打断当前轮次(树杀子进程,历史还原到本轮开始前,不留残半的工具调用);`/rollback` 按轮次快照恢复文件改动,逐个文件「保留/撤销」,不依赖 git。
|
|
84
|
+
- **输入安全网** — 长 prompt 不再怕误按 Enter:`Ctrl+G` 弹出 TUI 内「输入面板」(记事本式编辑,Enter=换行、软换行、选区、复制/剪切/粘贴、撤销,Ctrl+S 填回输入框不自动发送);`Ctrl+R`/`Ctrl+P` 模糊搜索历史输入(Enter 只回填不发送);长文本误发后撤回窗口自动放宽到 2 秒且任意键可撤回。
|
|
84
85
|
- **沙箱防护** — 文件读写经沙箱拦截,挡掉越界路径(`../../`、绝对外圈、软链出圈等),不碰工作目录之外的文件。
|
|
85
86
|
|
|
86
87
|
## 特性
|
package/dist/i18n/index.js
CHANGED
|
@@ -107,6 +107,12 @@ const zhCN = {
|
|
|
107
107
|
'prompt.revertDetail': '({count} 个文件恢复到回滚前)',
|
|
108
108
|
'prompt.messagesOnly': '只撤销消息,保留文件改动',
|
|
109
109
|
'prompt.emptyPlaceholder': '输入消息,按 Enter 开始对话;输入 /help 查看命令…',
|
|
110
|
+
'prompt.confirmSendHint': '再按一次 Enter 发送 · 其它键继续编辑',
|
|
111
|
+
'composer.title': '输入面板',
|
|
112
|
+
'composer.pos': '{row}/{total} 行 · {col} 列 · {chars} 字',
|
|
113
|
+
'composer.hint': 'Enter 换行 · Ctrl+S 回填 · Esc 取消 · Ctrl+Z 撤销 · Ctrl+C/X/V 复制/剪切/粘贴 · Ctrl+A 全选',
|
|
114
|
+
'history.hint': '输入过滤 · ↑↓ 选择 · Enter 填入输入框 · Ctrl+Enter 直接发送 · Esc 取消',
|
|
115
|
+
'history.empty': '(无匹配的历史输入)',
|
|
110
116
|
'welcome.gettingStarted': '快速上手',
|
|
111
117
|
'welcome.start1': '直接输入消息,按 Enter 发送;Shift+Tab 切换 auto / plan 模式',
|
|
112
118
|
'welcome.start2': '输入 / 打开命令菜单;输入 /help 查看全部命令',
|
|
@@ -141,7 +147,7 @@ const zhCN = {
|
|
|
141
147
|
'repl.didYouMean': '你是不是想输入 {value}?',
|
|
142
148
|
'repl.unknownCommandHint': '输入 /help 查看全部命令(普通文本直接打字发送,不必加 /)',
|
|
143
149
|
'theme.default': '16 色原版(深底)',
|
|
144
|
-
'theme.light': '
|
|
150
|
+
'theme.light': 'Solarized 浅色系',
|
|
145
151
|
'theme.solarized': 'Solarized 强调色',
|
|
146
152
|
'theme.gruvbox': 'Gruvbox 暖色',
|
|
147
153
|
'theme.nord': 'Nord 冷色',
|
|
@@ -172,7 +178,7 @@ const zhCN = {
|
|
|
172
178
|
'status.measured': '实测',
|
|
173
179
|
'status.estimated': '估算',
|
|
174
180
|
'status.messages': '{count} 条消息',
|
|
175
|
-
'agent.sending': '发送中… (Esc / Ctrl+C 撤回)',
|
|
181
|
+
'agent.sending': '发送中… (任意键 / Esc / Ctrl+C 撤回)',
|
|
176
182
|
'agent.thinking': '思考中',
|
|
177
183
|
'agent.generating': '生成 {tool}',
|
|
178
184
|
'agent.executing': '执行 {tool}',
|
|
@@ -391,6 +397,12 @@ const en = {
|
|
|
391
397
|
'prompt.revertDetail': ' ({count} files restored to before the rollback)',
|
|
392
398
|
'prompt.messagesOnly': 'Roll back messages only; keep file changes',
|
|
393
399
|
'prompt.emptyPlaceholder': 'Type a message, press Enter to send; /help for commands…',
|
|
400
|
+
'prompt.confirmSendHint': 'Press Enter again to send · any other key keeps editing',
|
|
401
|
+
'composer.title': 'Composer',
|
|
402
|
+
'composer.pos': '{row}/{total} lines · col {col} · {chars} chars',
|
|
403
|
+
'composer.hint': 'Enter Newline · Ctrl+S Fill input · Esc Cancel · Ctrl+Z Undo · Ctrl+C/X/V Copy/Cut/Paste · Ctrl+A Select all · Shift+Arrows Select',
|
|
404
|
+
'history.hint': 'Type to filter · ↑↓ Select · Enter Fill input · Ctrl+Enter Send · Esc Cancel',
|
|
405
|
+
'history.empty': '(no matching history)',
|
|
394
406
|
'welcome.gettingStarted': 'Getting started',
|
|
395
407
|
'welcome.start1': 'Type a message and press Enter; Shift+Tab toggles auto / plan mode',
|
|
396
408
|
'welcome.start2': 'Type / for the command menu; /help lists all commands',
|
|
@@ -425,7 +437,7 @@ const en = {
|
|
|
425
437
|
'repl.didYouMean': 'Did you mean {value}?',
|
|
426
438
|
'repl.unknownCommandHint': 'Run /help for all commands (plain text is sent as-is, no slash needed)',
|
|
427
439
|
'theme.default': 'Original 16-color theme (dark)',
|
|
428
|
-
'theme.light': 'Light
|
|
440
|
+
'theme.light': 'Light Solarized palette',
|
|
429
441
|
'theme.solarized': 'Solarized accents',
|
|
430
442
|
'theme.gruvbox': 'Warm Gruvbox colors',
|
|
431
443
|
'theme.nord': 'Cool Nord colors',
|
|
@@ -456,7 +468,7 @@ const en = {
|
|
|
456
468
|
'status.measured': 'measured',
|
|
457
469
|
'status.estimated': 'estimated',
|
|
458
470
|
'status.messages': '{count} messages',
|
|
459
|
-
'agent.sending': 'Sending… (Esc / Ctrl+C to recall)',
|
|
471
|
+
'agent.sending': 'Sending… (any key / Esc / Ctrl+C to recall)',
|
|
460
472
|
'agent.thinking': 'Thinking',
|
|
461
473
|
'agent.generating': 'Generating {tool}',
|
|
462
474
|
'agent.executing': 'Running {tool}',
|
package/dist/repl/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { runAgent } from '../agent/index.js';
|
|
|
10
10
|
import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
|
|
11
11
|
import { togglePet, killPetProcess, listSkins, setSkin, sendState } from '../pet/bridge.js';
|
|
12
12
|
import { setSandboxRoot } from '../sandbox/root.js';
|
|
13
|
-
import { ui, setTheme, getTheme, listThemes, themeExists
|
|
13
|
+
import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js';
|
|
14
14
|
import { bannerLines, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
|
|
15
15
|
import * as layout from '../ui/layout.js';
|
|
16
16
|
import * as mouse from '../ui/mouse.js';
|
|
@@ -35,6 +35,7 @@ import fs from 'node:fs';
|
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import { getSandboxRoot } from '../sandbox/root.js';
|
|
37
37
|
import { setCurrentSessionId, getCurrentSessionId } from '../session/state.js';
|
|
38
|
+
import { collectQueryHistory } from '../session/query-history.js';
|
|
38
39
|
import { checkVersion, fetchLatestVersion, getCurrentVersion, runUpgradeForeground, } from '../commands/upgrade.js';
|
|
39
40
|
/**
|
|
40
41
|
* readline 的 prompt 必须是纯文本(无 ANSI):readline 按字符数算光标位置,
|
|
@@ -457,7 +458,21 @@ let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 IN
|
|
|
457
458
|
// 500ms 内 Ctrl+C / Esc → 整条用户气泡从内容区擦掉 + 原行 prefilled 回输入框(可改可再发);
|
|
458
459
|
// 期间再按 Enter 立即推进 / 时间到自然推进 → 走原流程 enterRunningMode + runTurn。
|
|
459
460
|
// attachmentsCount 记 pendingAttachments 当时长度——撤回时 attachments 保留(用户意图未变,只是改字)。
|
|
461
|
+
// 短单行:500ms(不拖慢日常)。长文本:MOCODE_RECALL_MS,默认 2000ms —— 人发现"误发了"通常要 1-3 秒,
|
|
462
|
+
// 500ms 根本来不及反应,只能事后 /rollback。
|
|
460
463
|
const PENDING_RECALL_MS = 500;
|
|
464
|
+
const PENDING_RECALL_LONG_MS = 2000;
|
|
465
|
+
/** 长输入阈值:与 prompt.ts 的二次确认阈值保持一致(≥2 行 或 ≥120 码点)。 */
|
|
466
|
+
const LONG_INPUT_CHARS = 120;
|
|
467
|
+
/** 按输入长度决定撤回窗口:长 prompt 给更宽的补救时间,短单行仍走 500ms。 */
|
|
468
|
+
function recallWindowMs(input) {
|
|
469
|
+
const joined = input.join('\n');
|
|
470
|
+
const long = input.length >= 2 || [...joined].length >= LONG_INPUT_CHARS;
|
|
471
|
+
if (!long)
|
|
472
|
+
return PENDING_RECALL_MS;
|
|
473
|
+
const env = Number(process.env.MOCODE_RECALL_MS);
|
|
474
|
+
return Number.isFinite(env) && env >= 0 ? env : PENDING_RECALL_LONG_MS;
|
|
475
|
+
}
|
|
461
476
|
let pendingRecall = null;
|
|
462
477
|
let pendingTimer = null;
|
|
463
478
|
// agent 模式状态已提到 src/agent/mode.ts(共享叶子:switch_mode 工具可写、agent 每步读、repl 注册 onModeChange 监听器)。
|
|
@@ -688,15 +703,19 @@ function awaitPendingRecall(input, attachmentsCount, placeholder) {
|
|
|
688
703
|
finalize(false);
|
|
689
704
|
return;
|
|
690
705
|
}
|
|
691
|
-
//
|
|
706
|
+
// 确认立即发送:Enter / Return
|
|
692
707
|
if (key.name === 'enter' || key.name === 'return') {
|
|
693
708
|
finalize(true);
|
|
694
709
|
return;
|
|
695
710
|
}
|
|
696
|
-
//
|
|
711
|
+
// 其它任何键 → 撤回。
|
|
712
|
+
// 旧实现把非 Enter 键"忽略"掉:误按 Enter 后人的第一反应是狂敲键盘 / 按空格 / 按退格,
|
|
713
|
+
// 这一串输入被静默丢弃,白白错过补救窗口。撤回窗口期间输入框尚未接管键盘,
|
|
714
|
+
// 这些键本来也不会进输入框,吞掉它们没有任何收益 —— 全部当作撤回信号更有用。
|
|
715
|
+
finalize(false);
|
|
697
716
|
};
|
|
698
717
|
emitter.on('keypress', onPendingKey);
|
|
699
|
-
pendingTimer = setTimeout(() => finalize(true),
|
|
718
|
+
pendingTimer = setTimeout(() => finalize(true), recallWindowMs(input));
|
|
700
719
|
pendingTimer.unref?.();
|
|
701
720
|
});
|
|
702
721
|
}
|
|
@@ -1274,6 +1293,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1274
1293
|
prompt: PROMPT,
|
|
1275
1294
|
commands: buildSlashCommands(),
|
|
1276
1295
|
onCycleMode: cycleMode,
|
|
1296
|
+
// Ctrl+R / Ctrl+P 历史搜索候选源:惰性工厂——当前会话内存 queryHistory + 最近落盘会话,
|
|
1297
|
+
// 聚合要读盘,只有面板真正打开(Ctrl+R/P)才求值,平常轮次零开销
|
|
1298
|
+
history: () => collectQueryHistory(queryHistory).map((e) => e.text),
|
|
1277
1299
|
// /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
|
|
1278
1300
|
...(pendingPrefill
|
|
1279
1301
|
? { initialLines: pendingPrefill }
|
|
@@ -1880,7 +1902,6 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1880
1902
|
// 切:setTheme → 重算状态行(新色)→ 清内容重绘(历史 / 横幅,镜像启动 + /resume)→ 确认 → 持久化。
|
|
1881
1903
|
// markdown MEMO 按 themeVersion 自动失效,故 renderHistory 取新色;状态栏 / 输入框由 continue 回 INPUT 态时读 getter 刷。
|
|
1882
1904
|
setTheme(name);
|
|
1883
|
-
applyTerminalBackground();
|
|
1884
1905
|
refreshStatusBase(history);
|
|
1885
1906
|
layout.clearContent();
|
|
1886
1907
|
if (history.some((m) => m.role === 'user')) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { listSessions, loadSession } from './persist.js';
|
|
2
|
+
/** 当前会话条目的 sessionId 标记。 */
|
|
3
|
+
export const CURRENT_SESSION = 'current';
|
|
4
|
+
export function collectQueryHistory(current, opts = {}) {
|
|
5
|
+
const maxSessions = Math.max(0, opts.maxSessions ?? 20);
|
|
6
|
+
const maxEntries = Math.max(1, opts.maxEntries ?? 500);
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
const out = [];
|
|
9
|
+
const push = (raw, sessionId, at) => {
|
|
10
|
+
if (out.length >= maxEntries)
|
|
11
|
+
return;
|
|
12
|
+
const text = raw.trim();
|
|
13
|
+
if (!text || seen.has(text))
|
|
14
|
+
return;
|
|
15
|
+
seen.add(text);
|
|
16
|
+
out.push({ text, sessionId, at });
|
|
17
|
+
};
|
|
18
|
+
// 当前会话:数组末尾最新 → 倒序,让"刚发的"排最前。
|
|
19
|
+
for (let i = current.length - 1; i >= 0; i--)
|
|
20
|
+
push(current[i] ?? '', CURRENT_SESSION, '');
|
|
21
|
+
if (out.length < maxEntries && maxSessions > 0) {
|
|
22
|
+
// listSessions 已按 createdAt 降序(最新在前);损坏文件内部跳过。
|
|
23
|
+
for (const meta of listSessions(maxSessions)) {
|
|
24
|
+
let rec;
|
|
25
|
+
try {
|
|
26
|
+
rec = loadSession(meta.id);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const qh = rec?.queryHistory;
|
|
32
|
+
if (!qh?.length)
|
|
33
|
+
continue;
|
|
34
|
+
for (let i = qh.length - 1; i >= 0; i--)
|
|
35
|
+
push(qh[i] ?? '', meta.id, meta.createdAt ?? '');
|
|
36
|
+
if (out.length >= maxEntries)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** 取展示用首行(多行 query 折叠成一行;换行符显示为 ␊)。 */
|
|
43
|
+
export function historyFirstLine(text) {
|
|
44
|
+
return text.split('\n')[0] ?? '';
|
|
45
|
+
}
|
package/dist/ui/batch.js
CHANGED
|
@@ -275,14 +275,20 @@ function buildEntryDetailLines(e, indent = ' ') {
|
|
|
275
275
|
}
|
|
276
276
|
return lines;
|
|
277
277
|
}
|
|
278
|
+
/** entry 第一层明细行的结果指纹:结果/失败态/diff 有变时指纹变,驱动运行态原位刷新。 */
|
|
279
|
+
function entryLineDigest(e) {
|
|
280
|
+
return `${e.resultSummary}\u0001${e.diffBlock ? '1' : '0'}\u0001${e.failed ? '1' : '0'}`;
|
|
281
|
+
}
|
|
282
|
+
/** 单条第一层明细行。isLast 决定分支符(└─/├─);结果回填原位替换时也要用对分支符。 */
|
|
283
|
+
function buildEntryLine(e, index, isLast, extraIndent = '') {
|
|
284
|
+
const result = e.resultSummary ? ` ${ui.gray}↳ ${e.resultSummary}${ui.reset}` : '';
|
|
285
|
+
const branch = isLast ? '└─' : '├─';
|
|
286
|
+
const failure = e.failed ? `${ui.red}×${ui.reset} ` : '';
|
|
287
|
+
return sanitizeRow(`${extraIndent} ${ui.dim}${branch}${ui.reset} ${failure}${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}`);
|
|
288
|
+
}
|
|
278
289
|
/** 第一层只展示有哪些调用及其简短结果,不展开完整输出。extraIndent 供子批嵌套加深缩进。 */
|
|
279
290
|
function buildExpandedLines(entries, extraIndent = '') {
|
|
280
|
-
return entries.map((e, index) =>
|
|
281
|
-
const result = e.resultSummary ? ` ${ui.gray}↳ ${e.resultSummary}${ui.reset}` : '';
|
|
282
|
-
const branch = index === entries.length - 1 ? '└─' : '├─';
|
|
283
|
-
const failure = e.failed ? `${ui.red}×${ui.reset} ` : '';
|
|
284
|
-
return sanitizeRow(`${extraIndent} ${ui.dim}${branch}${ui.reset} ${failure}${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}`);
|
|
285
|
-
});
|
|
291
|
+
return entries.map((e, index) => buildEntryLine(e, index, index === entries.length - 1, extraIndent));
|
|
286
292
|
}
|
|
287
293
|
function entryDetailIndent(entries, index) {
|
|
288
294
|
return index < entries.length - 1 ? ' │ ' : ' ';
|
|
@@ -295,7 +301,8 @@ export function endBatch(id, layout) {
|
|
|
295
301
|
b.finishedAt ??= Date.now();
|
|
296
302
|
if (b.summaryAbsIdx >= 0) {
|
|
297
303
|
layout.contentReplaceLine?.(b.summaryAbsIdx, buildSummaryLine(b));
|
|
298
|
-
//
|
|
304
|
+
// 点击命中在 showLiveBatch 首次落盘时即已登记(运行态可展开);这里幂等重登,
|
|
305
|
+
// 并把摘要行刷成最终完成态文案。
|
|
299
306
|
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
300
307
|
return;
|
|
301
308
|
}
|
|
@@ -323,7 +330,9 @@ export function endBatch(id, layout) {
|
|
|
323
330
|
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
324
331
|
}
|
|
325
332
|
/**
|
|
326
|
-
*
|
|
333
|
+
* 工具执行中立即显示/刷新摘要,并登记点击命中——运行态即可展开/折叠(此前只在
|
|
334
|
+
* endBatch 收尾后开放,导致「连续无正文的工具链」在整轮结束前无法展开,用户报告痛点)。
|
|
335
|
+
* 展开态下由 syncLiveExpanded 保持明细视图与新 entry/结果同步。
|
|
327
336
|
*/
|
|
328
337
|
export function showLiveBatch(id, layout) {
|
|
329
338
|
const b = batches.get(id);
|
|
@@ -360,10 +369,12 @@ export function showLiveBatch(id, layout) {
|
|
|
360
369
|
}
|
|
361
370
|
layout.contentInsertAfter(anchor, [sanitizeRow(summary)], false);
|
|
362
371
|
b.summaryAbsIdx = anchor + 1;
|
|
372
|
+
absLineToBatchId.set(b.summaryAbsIdx, b.id); // 运行态同样开放点击(endBatch 幂等重登)
|
|
363
373
|
return;
|
|
364
374
|
}
|
|
365
375
|
layout.contentWrite(summary + '\n');
|
|
366
376
|
b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
|
|
377
|
+
absLineToBatchId.set(b.summaryAbsIdx, b.id); // 运行态同样开放点击
|
|
367
378
|
// 首条摘要通过增量 contentWrite 落屏时,markdown→普通内容的边界可能只更新了
|
|
368
379
|
// buffer/续写位;直到第二个 header 的 contentReplaceLine 或后续正文重绘才完全可见。
|
|
369
380
|
// 立即按 buffer 原子重画,确保慢工具执行期间摘要前的空行已经显示。
|
|
@@ -377,6 +388,30 @@ export function showLiveBatch(id, layout) {
|
|
|
377
388
|
else {
|
|
378
389
|
layout.contentReplaceLine(b.summaryAbsIdx, summary);
|
|
379
390
|
}
|
|
391
|
+
// 展开态的运行态同步:已渲染 entry 的结果回填原位替换 + 新 entry 追加。
|
|
392
|
+
syncLiveExpanded(b, layout);
|
|
393
|
+
}
|
|
394
|
+
/** 展开态 batch 的运行态同步(每次 showLiveBatch 后调):不展开/未落盘时 no-op。 */
|
|
395
|
+
function syncLiveExpanded(b, layout) {
|
|
396
|
+
if (!expandedBatches.has(b.id) || b.summaryAbsIdx < 0)
|
|
397
|
+
return;
|
|
398
|
+
// ① 已渲染 entry 的结果晚于渲染到达 → 原位替换该明细行(否则展开视图永远停在「无结果」)。
|
|
399
|
+
for (let i = 0; i < b.renderedCount && i < b.entries.length; i++) {
|
|
400
|
+
const e = b.entries[i];
|
|
401
|
+
const digest = entryLineDigest(e);
|
|
402
|
+
if (e.renderedDigest === digest)
|
|
403
|
+
continue;
|
|
404
|
+
const abs = findParentEntryAbsLine(b.id, i);
|
|
405
|
+
if (abs == null)
|
|
406
|
+
continue;
|
|
407
|
+
layout.contentReplaceLine(abs, buildEntryLine(e, i, i === b.entries.length - 1, b.indent ?? ''));
|
|
408
|
+
e.renderedDigest = digest;
|
|
409
|
+
}
|
|
410
|
+
// ② 新 entry(运行态 recordCall 后)追加到明细区末尾。
|
|
411
|
+
const ins = layout.contentInsertAfter;
|
|
412
|
+
if (ins && b.entries.length > b.renderedCount) {
|
|
413
|
+
refreshBatchExpanded(b.id, { contentInsertAfter: ins });
|
|
414
|
+
}
|
|
380
415
|
}
|
|
381
416
|
/** 绝对行索引 → 命中 batch id(用于鼠标 release 反查);非摘要行返 null。 */
|
|
382
417
|
export function findBatchByAbsLine(absLine) {
|
|
@@ -412,12 +447,25 @@ export function refreshBatchExpanded(id, layout) {
|
|
|
412
447
|
// 实时追加:不锚定视口,让新明细行自然出现在屏底。
|
|
413
448
|
// 组容器批的 entry 与子批摘要行交错,新 entry 必须插在当前块末尾,
|
|
414
449
|
// 不能简单用 summaryAbsIdx+renderedCount(否则 entry 会插到前一个子批摘要行之前)。
|
|
450
|
+
// 块末还必须计入已渲染 entry 的二层展开明细行(toggleEntry 把明细插在 entry 行下方,
|
|
451
|
+
// 会把后续 entry 行整体下移;运行态可交互后这条路径高频,漏算会插错位)。
|
|
415
452
|
let anchor = b.summaryAbsIdx + b.renderedCount;
|
|
453
|
+
for (const j of b.expandedEntries) {
|
|
454
|
+
if (j < b.renderedCount) {
|
|
455
|
+
anchor += buildEntryDetailLines(b.entries[j], entryDetailIndent(b.entries, j)).length;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
416
458
|
if (b.groupParent) {
|
|
417
|
-
let maxIdx =
|
|
459
|
+
let maxIdx = anchor;
|
|
418
460
|
for (const [idx, target] of absLineToEntry) {
|
|
419
|
-
if (target.batchId
|
|
420
|
-
|
|
461
|
+
if (target.batchId !== b.id)
|
|
462
|
+
continue;
|
|
463
|
+
// entry 行自身(含其二层明细)的块末位置
|
|
464
|
+
const end = b.expandedEntries.has(target.entryIndex)
|
|
465
|
+
? idx + buildEntryDetailLines(b.entries[target.entryIndex], entryDetailIndent(b.entries, target.entryIndex)).length
|
|
466
|
+
: idx;
|
|
467
|
+
if (end > maxIdx)
|
|
468
|
+
maxIdx = end;
|
|
421
469
|
}
|
|
422
470
|
for (const child of batches.values()) {
|
|
423
471
|
if (child.parentId === b.id && child.summaryAbsIdx >= 0) {
|
|
@@ -438,6 +486,7 @@ export function refreshBatchExpanded(id, layout) {
|
|
|
438
486
|
// 登记新增明细行的点击命中(按实际插入位置)
|
|
439
487
|
for (let i = 0; i < newEntries.length; i++) {
|
|
440
488
|
absLineToEntry.set(anchor + 1 + i, { batchId: b.id, entryIndex: b.renderedCount + i });
|
|
489
|
+
newEntries[i].renderedDigest = entryLineDigest(newEntries[i]);
|
|
441
490
|
}
|
|
442
491
|
b.renderedCount = b.entries.length;
|
|
443
492
|
}
|
|
@@ -468,6 +517,7 @@ function expand(b, layout, live = false) {
|
|
|
468
517
|
b.renderedCount = b.entries.length;
|
|
469
518
|
for (let i = 0; i < b.entries.length; i++) {
|
|
470
519
|
absLineToEntry.set(b.summaryAbsIdx + 1 + i, { batchId: b.id, entryIndex: i });
|
|
520
|
+
b.entries[i].renderedDigest = entryLineDigest(b.entries[i]); // 记指纹,供运行态结果回填原位刷新
|
|
471
521
|
}
|
|
472
522
|
// 组容器批展开时:把之前被折叠隐藏的子批摘要行重新插回对应 entry 下方,
|
|
473
523
|
// 否则子批摘要行会留在父批摘要行之后、造成明细重复/错位。
|
|
@@ -507,6 +557,7 @@ export function expandSingleEntryFully(id, layout) {
|
|
|
507
557
|
layout.contentWrite('\n');
|
|
508
558
|
expandedBatches.add(id);
|
|
509
559
|
b.expandedEntries.add(0);
|
|
560
|
+
b.entries[0].renderedDigest = entryLineDigest(b.entries[0]);
|
|
510
561
|
absLineToEntry.set(b.summaryAbsIdx + 1, { batchId: id, entryIndex: 0 });
|
|
511
562
|
}
|
|
512
563
|
function collapse(b, layout) {
|
package/dist/ui/clipboard.js
CHANGED
|
@@ -6,8 +6,9 @@ import { spawn } from 'node:child_process';
|
|
|
6
6
|
* 写入(copyToClipboard):选区复制(layout 鼠标选择)与 /copy 等命令共用,双通道:
|
|
7
7
|
* 1. OSC 52(`\x1B]52;c;<base64>\x07`):请求终端把文本写入系统剪贴板。经终端转发,SSH / 远程也生效;
|
|
8
8
|
* Windows Terminal ≥1.16、iTerm2、kitty、WezTerm 等支持(部分终端默认关,需开"允许应用访问剪贴板")。
|
|
9
|
-
* 2. 本地原生工具(best-effort,失败静默):win32=
|
|
10
|
-
*
|
|
9
|
+
* 2. 本地原生工具(best-effort,失败静默):win32=PowerShell Set-Clipboard(stdin 显式 Unicode 编码,
|
|
10
|
+
* 保证剪贴板正文无 BOM/杂字符——clip.exe 会把 BOM 字符 U+FEFF 留进正文,终端级粘贴会把它敲进输入框)、
|
|
11
|
+
* darwin=pbcopy、linux=wl-copy / xclip / xsel。覆盖 OSC52 被禁用的场景(如部分 VS Code 集成终端配置)。
|
|
11
12
|
* 两条都发:哪条生效由环境决定,重复写同一内容无副作用。
|
|
12
13
|
*
|
|
13
14
|
* 读取(readClipboard):OSC 52 是单向的(终端不会把剪贴板内容回传给应用,即便发 `\x1B]52;c;?\x07`
|
|
@@ -41,9 +42,23 @@ function pipeTo(cmd, args, buf) {
|
|
|
41
42
|
/** 本地原生剪贴板(best-effort)。 */
|
|
42
43
|
function nativeCopy(text) {
|
|
43
44
|
if (platform === 'win32') {
|
|
44
|
-
// clip.exe
|
|
45
|
-
//
|
|
46
|
-
|
|
45
|
+
// 不走 clip.exe 直写:BOM-less UTF-16LE 会被按控制台代码页(GBK)解码成乱码,
|
|
46
|
+
// 而 BOM 版虽能正确解码,clip.exe 却会把 BOM 字符(U+FEFF)留进剪贴板正文——
|
|
47
|
+
// 终端级粘贴(Ctrl+V 由终端直接灌键盘输入)绕过 readClipboard 的 FEFF 剥离,
|
|
48
|
+
// FEFF 会敲进输入框渲染成缺字形方块。故改走 PowerShell stdin:显式 Unicode
|
|
49
|
+
// 输入编码,无 BOM、无杂字符;PowerShell 拉不起(spawn error)才兜底 clip.exe+BOM。
|
|
50
|
+
const buf = Buffer.from(text, 'utf16le');
|
|
51
|
+
const bom = Buffer.from([0xff, 0xfe]);
|
|
52
|
+
try {
|
|
53
|
+
const p = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
|
54
|
+
'[Console]::InputEncoding=[System.Text.Encoding]::Unicode; Set-Clipboard -Value ([Console]::In.ReadToEnd())'], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
|
|
55
|
+
p.on('error', () => pipeTo('clip', [], Buffer.concat([bom, buf]))); // 无 PowerShell:退 clip.exe(乱码/FEFF 风险自负,OSC52 兜底)
|
|
56
|
+
p.stdin.on('error', () => { });
|
|
57
|
+
p.stdin.end(buf);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
pipeTo('clip', [], Buffer.concat([bom, buf]));
|
|
61
|
+
}
|
|
47
62
|
}
|
|
48
63
|
else if (platform === 'darwin') {
|
|
49
64
|
pipeTo('pbcopy', [], Buffer.from(text, 'utf8'));
|
|
@@ -101,7 +116,10 @@ export async function readClipboard() {
|
|
|
101
116
|
// Get-Clipboard 默认按控制台代码页输出;显式转 UTF8 避免中文乱码。
|
|
102
117
|
const out = await captureOutput('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
|
103
118
|
'[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-Clipboard -Raw'], 'utf8');
|
|
104
|
-
|
|
119
|
+
// 末尾 strip 一个 \n:PowerShell 把字符串写到 stdout 时会追加行终止符(实验证实
|
|
120
|
+
// clipboard=abc 时读回 abc\r\n),属输出伪影而非剪贴板内容;真以换行结尾的剪贴板
|
|
121
|
+
// 内容多出的一个 \n 恰好被抵消,不受影响。
|
|
122
|
+
return out?.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r$/, '').replace(/\n$/, '') ?? '';
|
|
105
123
|
}
|
|
106
124
|
if (platform === 'darwin') {
|
|
107
125
|
return (await captureOutput('pbpaste', [], 'utf8')) ?? '';
|