mocode-ai 1.3.9 → 1.4.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.
- package/README.md +78 -70
- package/README.zh-CN.md +81 -73
- package/bin/mocode-agent-host.js +1 -1
- package/dist/agent/core.js +226 -293
- package/dist/agent/index.js +14 -10
- package/dist/agent/mode.js +3 -3
- package/dist/agent/runtime-context.js +45 -0
- package/dist/agent/spawn.js +101 -86
- package/dist/agent/tool-helpers.js +134 -0
- package/dist/agent/trace-state.js +118 -0
- package/dist/agents/coordinator.js +6 -1
- package/dist/changeset/index.js +9 -8
- package/dist/config/index.js +109 -77
- package/dist/config/presets.js +3 -1
- package/dist/config/profiles.js +125 -0
- package/dist/context/age-aware.js +2 -4
- package/dist/context/artifacts.js +6 -13
- package/dist/context/budget.js +6 -8
- package/dist/context/classifier.js +1 -2
- package/dist/context/encoders/code.js +2 -6
- package/dist/context/encoders/command.js +1 -3
- package/dist/context/encoders/search.js +1 -3
- package/dist/context/index.js +1 -1
- package/dist/context/lifecycle.js +5 -3
- package/dist/context/pipeline.js +1 -1
- package/dist/context/relevance.js +2 -4
- package/dist/context/token-calibration.js +13 -18
- package/dist/host/stdio.js +73 -12
- package/dist/i18n/index.js +96 -44
- package/dist/index.js +1 -1
- package/dist/llm/index.js +39 -23
- package/dist/llm/provider.js +23 -0
- package/dist/llm/providers/anthropic.js +12 -18
- package/dist/llm/tool-schema.js +48 -0
- package/dist/mcp/client.js +70 -25
- package/dist/mcp/config.js +1 -3
- package/dist/mcp/index.js +3 -1
- package/dist/memory/reflect.js +3 -6
- package/dist/memory/store.js +10 -12
- package/dist/permissions/index.js +66 -22
- package/dist/pet/bridge.js +2 -2
- package/dist/repl/commands/appearance.js +72 -0
- package/dist/repl/commands/compact.js +114 -0
- package/dist/repl/commands/context.js +17 -0
- package/dist/repl/commands/image.js +71 -0
- package/dist/repl/commands/memory.js +51 -0
- package/dist/repl/commands/mode.js +47 -0
- package/dist/repl/commands/model.js +435 -0
- package/dist/repl/commands/pet.js +70 -0
- package/dist/repl/commands/registry.js +46 -0
- package/dist/repl/commands/session.js +102 -0
- package/dist/repl/commands/skill.js +95 -0
- package/dist/repl/commands/system.js +202 -0
- package/dist/repl/commands/tool-group.js +233 -0
- package/dist/repl/commands/types.js +4 -0
- package/dist/repl/commands.js +324 -0
- package/dist/repl/index.js +5 -2577
- package/dist/repl/message-format.js +151 -0
- package/dist/repl/running-input.js +147 -0
- package/dist/repl/runtime.js +816 -0
- package/dist/repl/status-bar.js +161 -0
- package/dist/rollback/index.js +44 -14
- package/dist/runtime/browser-manager.js +1 -1
- package/dist/runtime/dev-server-manager.js +1 -1
- package/dist/runtime/input-injector.js +313 -0
- package/dist/runtime/screen-capture.js +89 -0
- package/dist/runtime/screen-pipeline.js +223 -0
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/policy.js +8 -2
- package/dist/session/compact.js +22 -34
- package/dist/session/index.js +2 -2
- package/dist/session/notes.js +39 -5
- package/dist/session/persist.js +14 -8
- package/dist/session/scheduler.js +3 -4
- package/dist/session/trace-metrics.js +6 -3
- package/dist/skills/activation.js +10 -5
- package/dist/skills/discover.js +5 -17
- package/dist/skills/index.js +1 -1
- package/dist/skills/runner.js +28 -44
- package/dist/skills/toolmap.js +5 -2
- package/dist/tools/builtins/browser.js +17 -3
- package/dist/tools/builtins/computer.js +322 -0
- package/dist/tools/builtins/dev-server.js +1 -3
- package/dist/tools/builtins/edit-file.js +21 -7
- package/dist/tools/builtins/grep.js +1 -1
- package/dist/tools/builtins/index.js +28 -25
- package/dist/tools/builtins/memory-forget.js +1 -1
- package/dist/tools/builtins/memory-graph.js +4 -7
- package/dist/tools/builtins/memory-list.js +2 -6
- package/dist/tools/builtins/memory-save.js +8 -2
- package/dist/tools/builtins/memory-search.js +4 -4
- package/dist/tools/builtins/note-append.js +14 -3
- package/dist/tools/builtins/plan-update.js +40 -9
- package/dist/tools/builtins/run-command.js +8 -8
- package/dist/tools/builtins/screenshot.js +11 -87
- package/dist/tools/builtins/task.js +29 -40
- package/dist/tools/builtins/use-skill.js +1 -1
- package/dist/tools/builtins/view-image.js +5 -3
- package/dist/tools/builtins/web-fetch.js +1 -3
- package/dist/tools/builtins/web-search.js +3 -7
- package/dist/tools/builtins/write-file.js +4 -2
- package/dist/tools/constants.js +50 -37
- package/dist/tools/policy.js +221 -0
- package/dist/tools/registry.js +40 -19
- package/dist/tools/resource-lock.js +4 -2
- package/dist/tools/router.js +143 -0
- package/dist/tools/validation.js +6 -5
- package/dist/ui/batch.js +63 -23
- package/dist/ui/clipboard.js +17 -5
- package/dist/ui/content.js +166 -13
- package/dist/ui/diff.js +61 -19
- package/dist/ui/fuzzy.js +35 -2
- package/dist/ui/intervention.js +2 -4
- package/dist/ui/layout-internal/content-write.js +587 -0
- package/dist/ui/layout-internal/core.js +590 -0
- package/dist/ui/layout-internal/input-paint.js +365 -0
- package/dist/ui/layout-internal/screen.js +128 -0
- package/dist/ui/layout-internal/scroll.js +152 -0
- package/dist/ui/layout-internal/selection.js +229 -0
- package/dist/ui/layout-internal/state.js +82 -0
- package/dist/ui/layout-internal/statusbar.js +351 -0
- package/dist/ui/layout-types.js +1 -0
- package/dist/ui/layout.js +3 -2288
- package/dist/ui/markdown.js +10 -2
- package/dist/ui/prompt-internal/editor.js +945 -0
- package/dist/ui/prompt-internal/paste.js +52 -0
- package/dist/ui/prompt-internal/pickers.js +519 -0
- package/dist/ui/prompt-internal/types.js +1 -0
- package/dist/ui/prompt.js +4 -1506
- package/dist/ui/render.js +87 -2
- package/dist/verification/affected.js +11 -5
- package/dist/verification/discovery.js +1 -3
- package/dist/verification/profile.js +11 -3
- package/package.json +11 -2
package/dist/ui/prompt.js
CHANGED
|
@@ -1,1506 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import * as layout from './layout.js';
|
|
6
|
-
import * as mouse from './mouse.js';
|
|
7
|
-
import { t } from '../i18n/index.js';
|
|
8
|
-
import { promptComposer } from './composer.js';
|
|
9
|
-
import { promptHistorySearch } from './history-picker.js';
|
|
10
|
-
// ── 二次确认发送(MOCODE_CONFIRM_SEND=long 时启用,默认关)──
|
|
11
|
-
// 长 prompt 首次 Enter 只武装,窗口内再按一次才真发。默认 opt-in:新交互默认不打扰既有习惯。
|
|
12
|
-
const CONFIRM_SEND_MS = 2000;
|
|
13
|
-
const LONG_INPUT_CHARS = 120;
|
|
14
|
-
/** 二次确认模式:only 'long' 会启用(长/多行输入需按两次 Enter)。每次读 env,便于运行期改配置生效。 */
|
|
15
|
-
function confirmSendMode() {
|
|
16
|
-
return process.env.MOCODE_CONFIRM_SEND === 'long' ? 'long' : 'never';
|
|
17
|
-
}
|
|
18
|
-
// ── 粘贴检测(块级 + 时间窗)──
|
|
19
|
-
// 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
|
|
20
|
-
// 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
|
|
21
|
-
// 同一个 pasteParts、末尾一次性落 chip——避免"首块成 chip、后续块泄成文本"。粘贴中 onKey 把键累积进
|
|
22
|
-
// pasteParts(不编辑 lines)。不启用 bracketed paste——emitKeypressEvents 会把 \x1B[200~ 标记当按键砸进输入框。
|
|
23
|
-
let pasting = false;
|
|
24
|
-
let pasteParts = [];
|
|
25
|
-
let pasteTimer = null;
|
|
26
|
-
let onPasteEnd = null; // 粘贴结束回调(prompt 注入:落 chip 或保留为文本)
|
|
27
|
-
let pasteDetectorInstalled = false;
|
|
28
|
-
function ensurePasteDetector() {
|
|
29
|
-
if (pasteDetectorInstalled)
|
|
30
|
-
return;
|
|
31
|
-
pasteDetectorInstalled = true;
|
|
32
|
-
stdin.on('data', (chunk) => {
|
|
33
|
-
const raw = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
34
|
-
// 剔除 SGR 鼠标报告(\x1B[<…M/m,连半截也匹配):拖拽选区时终端连续发 motion 报表,
|
|
35
|
-
// 一个 chunk 里可能拼多条(22+ 字符 > 16 阈值)误判粘贴 50ms、把后续真按键泄进 pasteParts。
|
|
36
|
-
const text = raw.replace(/\x1b\[<[0-9;]*[Mm]/g, '');
|
|
37
|
-
const hasNL = text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0;
|
|
38
|
-
// 按字符数(码点)判粘贴,非字节:CJK 汉字占 3 UTF-8 字节,旧阈值(len>8 字节)会把 IME 提交的
|
|
39
|
-
// 3-10 个汉字误判为粘贴 → 进 50ms 缓冲 → finalizePaste→insertText 落字(且旧 insertText 把光标
|
|
40
|
-
// 置插入文本长度而非末尾,致"后续打字插到行中间")。改:含换行(多行粘贴)或 >16 字符(大块单行
|
|
41
|
-
// 粘贴)才算粘贴;普通 IME 提交走正常按键路径(逐字直插、光标随进、无延迟)。
|
|
42
|
-
const charCount = [...text].length;
|
|
43
|
-
if (!(charCount > 16 || (charCount > 1 && hasNL)))
|
|
44
|
-
return;
|
|
45
|
-
pasting = true;
|
|
46
|
-
if (pasteTimer)
|
|
47
|
-
clearTimeout(pasteTimer);
|
|
48
|
-
const t = setTimeout(() => {
|
|
49
|
-
pasteTimer = null;
|
|
50
|
-
pasting = false;
|
|
51
|
-
onPasteEnd?.();
|
|
52
|
-
}, 50);
|
|
53
|
-
t.unref();
|
|
54
|
-
pasteTimer = t;
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
/** 非 TTY / 未进 alt screen 时退化为普通 readline 行输入(无菜单、单行)。 */
|
|
58
|
-
function questionFallback(prompt) {
|
|
59
|
-
return new Promise((res, rej) => {
|
|
60
|
-
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
61
|
-
rl.question(prompt, (answer) => {
|
|
62
|
-
rl.close();
|
|
63
|
-
res(answer);
|
|
64
|
-
});
|
|
65
|
-
rl.on('error', (e) => {
|
|
66
|
-
rl.close();
|
|
67
|
-
rej(e);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* 读多行输入;TUI 下经 layout.paintInput 把输入框画在固定底栏、斜杠菜单向上展开进内容区底。
|
|
73
|
-
* 换行:Ctrl+J / Alt+Enter / Shift+Enter(终端区分时)/ 粘贴的 LF。Enter 提交。返回行数组;null=空缓冲 Ctrl+D。
|
|
74
|
-
* 非 TTY 退化为 readline.question(单行,返回 [answer])。raw 模式下 Ctrl+C 先恢复终端再 reject('SIGINT')。
|
|
75
|
-
*
|
|
76
|
-
* 渲染全归 layout(prompt 只持有编辑状态:lines / 光标 / 菜单),prompt 不直接发 ANSI 区域控制,
|
|
77
|
-
* 避免 readline 光标错位与区域越界——颜色仅用在菜单行字符串里(由 layout 原样贴入)。
|
|
78
|
-
*/
|
|
79
|
-
export async function promptWithSlashMenu(opts) {
|
|
80
|
-
if (!layout.isActive()) {
|
|
81
|
-
const a = await questionFallback(opts.prompt);
|
|
82
|
-
return [a];
|
|
83
|
-
}
|
|
84
|
-
const emitter = stdin;
|
|
85
|
-
const promptW = displayWidth(opts.prompt);
|
|
86
|
-
// initialLines(运行中 typeahead 预填):用调用方给的行初始化;光标置文末。
|
|
87
|
-
// 整篇输入是一个 Seg[]:可编辑段与粘贴块交替。光标永远落在某个可编辑段(segs[curSeg].frozen===false)上。
|
|
88
|
-
let segs = opts.initialLines && opts.initialLines.length > 0
|
|
89
|
-
? [{ frozen: false, text: opts.initialLines.join('\n') }]
|
|
90
|
-
: [{ frozen: false, text: '' }];
|
|
91
|
-
let curSeg = 0; // 光标所在段(始终可编辑)
|
|
92
|
-
let curOff = segs[0].text.length; // 段内字符偏移 = 文末
|
|
93
|
-
let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
|
|
94
|
-
let menuOpen = false;
|
|
95
|
-
let selected = 0;
|
|
96
|
-
let filtered = [];
|
|
97
|
-
const MENU_MAX_VISIBLE = 7;
|
|
98
|
-
let menuTop = 0; // 窗口首项在 filtered 中的索引,菜单最多显示 MENU_MAX_VISIBLE 条
|
|
99
|
-
/** Ctrl+C 清空前的缓冲快照(Ctrl+Z 还原一次);单次生效,还原后即清。 */
|
|
100
|
-
let undoSnapshot = null;
|
|
101
|
-
/** 二次确认武装截止时刻(绝对 ms);0=未武装。见 submit() 与 MOCODE_CONFIRM_SEND。 */
|
|
102
|
-
let confirmArmedUntil = 0;
|
|
103
|
-
let resolved = false;
|
|
104
|
-
let resolve;
|
|
105
|
-
let reject;
|
|
106
|
-
/** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
|
|
107
|
-
function menuLines() {
|
|
108
|
-
if (!menuOpen || filtered.length === 0)
|
|
109
|
-
return [];
|
|
110
|
-
const cols = layout.getGeo().cols;
|
|
111
|
-
const visibleCount = Math.min(MENU_MAX_VISIBLE, filtered.length);
|
|
112
|
-
// 保 selected 在窗口内:selected 顶到上/下边界时才挪 menuTop
|
|
113
|
-
if (selected < menuTop)
|
|
114
|
-
menuTop = selected;
|
|
115
|
-
else if (selected >= menuTop + visibleCount)
|
|
116
|
-
menuTop = selected - visibleCount + 1;
|
|
117
|
-
const windowItems = filtered.slice(menuTop, menuTop + visibleCount);
|
|
118
|
-
const maxName = Math.max(...windowItems.map((item) => displayWidth(item.node.name + (item.node.children?.length ? ' ›' : ''))));
|
|
119
|
-
const hasMoreAbove = menuTop > 0;
|
|
120
|
-
const hasMoreBelow = menuTop + visibleCount < filtered.length;
|
|
121
|
-
return windowItems.map((item, i) => {
|
|
122
|
-
const c = item.node;
|
|
123
|
-
const globalIdx = menuTop + i;
|
|
124
|
-
// 选中项:▸ 与文字均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
125
|
-
const isSel = globalIdx === selected;
|
|
126
|
-
const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
|
|
127
|
-
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
128
|
-
const branchSuffix = c.children?.length ? ' ›' : '';
|
|
129
|
-
const name = padEndDisplay(c.name + branchSuffix, maxName);
|
|
130
|
-
// 首/末附加滚动指示(▲/▼)而非替换整行
|
|
131
|
-
const desc = c.desc;
|
|
132
|
-
let scrollHint = '';
|
|
133
|
-
if (i === 0 && hasMoreAbove)
|
|
134
|
-
scrollHint = ' ▲';
|
|
135
|
-
if (i === windowItems.length - 1 && hasMoreBelow)
|
|
136
|
-
scrollHint = ' ▼';
|
|
137
|
-
const hintW = displayWidth(scrollHint);
|
|
138
|
-
const descW = cols - maxName - 5 - hintW; // marker + 空格 + 2 间距 + hint
|
|
139
|
-
const descStr = descW > 0 ? truncateDisplay(desc, descW) : '';
|
|
140
|
-
return `${marker} ${color}${name}${ui.reset} ${color}${descStr}${ui.reset}${ui.dim}${scrollHint}${ui.reset}`;
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
/** 单个 chip 的展示 token:[📋 行数 · 字符数] (含尾部空格,使相邻 chip 之间留空隙)。 */
|
|
144
|
-
function chipToken(text) {
|
|
145
|
-
const lineCount = text.split('\n').length;
|
|
146
|
-
const charCount = text.length;
|
|
147
|
-
const lineText = t('prompt.lines', { count: lineCount });
|
|
148
|
-
const charText = t('prompt.chars', {
|
|
149
|
-
count: charCount < 1000 ? charCount : `${(charCount / 1000).toFixed(1)}K`,
|
|
150
|
-
});
|
|
151
|
-
return `[📋 ${lineText} · ${charText}] `;
|
|
152
|
-
}
|
|
153
|
-
/** 构建整段显示:可编辑段按原文(含换行)展开,粘贴块(chip)折叠成一行 token 内联在光标原位。
|
|
154
|
-
* 返回展示行数组,以及每个段在"展示串"(= 展示行以 \n 拼接)中的字符起点,供光标正/反向映射。
|
|
155
|
-
* 关键不变量:展示串与 segs 的"段贡献"长度一致(可编辑段贡献其原文长度,含其中 \n;
|
|
156
|
-
* chip 贡献其 token 长度,无 \n),故 segStart 同时是 dispLines.join('\n') 内的字符偏移。 */
|
|
157
|
-
function buildDisplay() {
|
|
158
|
-
const out = [];
|
|
159
|
-
const segStart = [];
|
|
160
|
-
let gLen = 0;
|
|
161
|
-
for (const s of segs) {
|
|
162
|
-
segStart.push(gLen);
|
|
163
|
-
if (!s.frozen) {
|
|
164
|
-
const ls = s.text.split('\n');
|
|
165
|
-
if (out.length === 0)
|
|
166
|
-
out.push(...ls);
|
|
167
|
-
else {
|
|
168
|
-
out[out.length - 1] += ls[0];
|
|
169
|
-
for (let k = 1; k < ls.length; k++)
|
|
170
|
-
out.push(ls[k]);
|
|
171
|
-
}
|
|
172
|
-
gLen += s.text.length;
|
|
173
|
-
}
|
|
174
|
-
else {
|
|
175
|
-
const tok = chipToken(s.text);
|
|
176
|
-
if (out.length === 0)
|
|
177
|
-
out.push(tok);
|
|
178
|
-
else
|
|
179
|
-
out[out.length - 1] += tok;
|
|
180
|
-
gLen += tok.length;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
if (out.length === 0)
|
|
184
|
-
out.push('');
|
|
185
|
-
return { lines: out, segStart };
|
|
186
|
-
}
|
|
187
|
-
/** 当前光标段内显示列。 */
|
|
188
|
-
function cursorCol() {
|
|
189
|
-
return displayWidth(segs[curSeg].text.slice(0, curOff));
|
|
190
|
-
}
|
|
191
|
-
/** 光标 → 展示 (行, 列)。展示行内的列以显示宽度计。 */
|
|
192
|
-
function cursorDisp() {
|
|
193
|
-
const { lines: dispLines, segStart } = buildDisplay();
|
|
194
|
-
const off = segStart[curSeg] + curOff;
|
|
195
|
-
let remaining = off;
|
|
196
|
-
let dl = 0;
|
|
197
|
-
let dispColChars = 0;
|
|
198
|
-
for (; dl < dispLines.length; dl++) {
|
|
199
|
-
const L = dispLines[dl].length;
|
|
200
|
-
if (remaining <= L) {
|
|
201
|
-
dispColChars = remaining;
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
remaining -= L + 1;
|
|
205
|
-
}
|
|
206
|
-
if (dl >= dispLines.length) {
|
|
207
|
-
dl = Math.max(0, dispLines.length - 1);
|
|
208
|
-
dispColChars = dispLines[dl].length;
|
|
209
|
-
}
|
|
210
|
-
return { line: dl, col: displayWidth(dispLines[dl].slice(0, dispColChars)) };
|
|
211
|
-
}
|
|
212
|
-
/** 展示 (行, 显示列) → 段内字符偏移。供 ↑/↓ 移动后重新定位,以及鼠标点击的反向映射。 */
|
|
213
|
-
function dispColToCharInLine(dispLine, dispCol) {
|
|
214
|
-
const { lines } = buildDisplay();
|
|
215
|
-
const ln = lines[dispLine] ?? '';
|
|
216
|
-
return visColToCharCol(ln, dispCol);
|
|
217
|
-
}
|
|
218
|
-
/** 展示 (行, 字符内偏移) → 段+偏移。光标永远落在可编辑段;点中 chip 则吸附到最近的可编辑边界。 */
|
|
219
|
-
function mapDispToCursor(dispLine, charInLine) {
|
|
220
|
-
const { lines: dispLines, segStart } = buildDisplay();
|
|
221
|
-
let off = 0;
|
|
222
|
-
for (let k = 0; k < dispLine && k < dispLines.length; k++)
|
|
223
|
-
off += dispLines[k].length + 1;
|
|
224
|
-
off += Math.max(0, Math.min(charInLine, (dispLines[dispLine] ?? '').length));
|
|
225
|
-
for (let i = 0; i < segs.length; i++) {
|
|
226
|
-
const start = segStart[i];
|
|
227
|
-
const len = segs[i].frozen ? chipToken(segs[i].text).length : segs[i].text.length;
|
|
228
|
-
// 可编辑段用闭区间;只读块用半开区间——其结束边界归紧随其后的可编辑段,
|
|
229
|
-
// 否则光标恰好停在 chip 后的可编辑段开头时,会被误判落在 chip 上(边界争用)。
|
|
230
|
-
const inside = segs[i].frozen ? off >= start && off < start + len : off >= start && off <= start + len;
|
|
231
|
-
if (inside) {
|
|
232
|
-
if (!segs[i].frozen)
|
|
233
|
-
return { seg: i, off: off - start };
|
|
234
|
-
// 落在只读块内:按就近原则吸附——靠近左沿→其前可编辑段末尾,靠近右沿→其后可编辑段开头。
|
|
235
|
-
const distStart = off - start;
|
|
236
|
-
const distEnd = start + len - off;
|
|
237
|
-
return distStart <= distEnd ? editableEndBefore(i) : editableStartAfter(i);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return lastEditableEnd();
|
|
241
|
-
}
|
|
242
|
-
function editableStartAfter(i) {
|
|
243
|
-
let j = i + 1;
|
|
244
|
-
while (j < segs.length && segs[j].frozen)
|
|
245
|
-
j++;
|
|
246
|
-
if (j < segs.length)
|
|
247
|
-
return { seg: j, off: 0 };
|
|
248
|
-
return lastEditableEnd();
|
|
249
|
-
}
|
|
250
|
-
function editableEndBefore(i) {
|
|
251
|
-
let j = i - 1;
|
|
252
|
-
while (j >= 0 && segs[j].frozen)
|
|
253
|
-
j--;
|
|
254
|
-
if (j >= 0)
|
|
255
|
-
return { seg: j, off: segs[j].text.length };
|
|
256
|
-
return firstEditableStart();
|
|
257
|
-
}
|
|
258
|
-
function firstEditableStart() {
|
|
259
|
-
for (let j = 0; j < segs.length; j++)
|
|
260
|
-
if (!segs[j].frozen)
|
|
261
|
-
return { seg: j, off: 0 };
|
|
262
|
-
return { seg: 0, off: 0 };
|
|
263
|
-
}
|
|
264
|
-
function lastEditableEnd() {
|
|
265
|
-
for (let j = segs.length - 1; j >= 0; j--)
|
|
266
|
-
if (!segs[j].frozen)
|
|
267
|
-
return { seg: j, off: segs[j].text.length };
|
|
268
|
-
return { seg: 0, off: 0 };
|
|
269
|
-
}
|
|
270
|
-
/** 在光标处插入文本(含换行则落进当前可编辑段,原样保留)。供短粘贴与可打印字符共用。 */
|
|
271
|
-
function insertText(text) {
|
|
272
|
-
const s = segs[curSeg].text;
|
|
273
|
-
segs[curSeg].text = s.slice(0, curOff) + text + s.slice(curOff);
|
|
274
|
-
curOff += text.length;
|
|
275
|
-
}
|
|
276
|
-
/** 落一段粘贴文本:长粘贴 → 在此处插入一个只读 chip(把当前可编辑段从光标切开成「前段 + chip + 后段」),
|
|
277
|
-
* 短粘贴 → 作为可编辑文本插入。连续长粘贴会产生相邻 chip,各自独立;两次粘贴之间敲的字是可编辑段,
|
|
278
|
-
* 可直接编辑、也可退格删掉任一 chip。光标落在 chip 之后的可编辑段(即使为空也保留,保证光标有落点)。 */
|
|
279
|
-
function applyPastedText(buf) {
|
|
280
|
-
if (!buf)
|
|
281
|
-
return;
|
|
282
|
-
const isLong = buf.split('\n').length > 8 || buf.length > 200;
|
|
283
|
-
if (!isLong) {
|
|
284
|
-
insertText(buf);
|
|
285
|
-
computeFiltered();
|
|
286
|
-
redraw();
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
const s = segs[curSeg].text;
|
|
290
|
-
const before = s.slice(0, curOff);
|
|
291
|
-
const after = s.slice(curOff);
|
|
292
|
-
const newSegs = [];
|
|
293
|
-
if (before)
|
|
294
|
-
newSegs.push({ frozen: false, text: before });
|
|
295
|
-
newSegs.push({ frozen: true, text: buf });
|
|
296
|
-
newSegs.push({ frozen: false, text: after }); // 始终保留可编辑后缀(可为空),保证光标有落点
|
|
297
|
-
segs.splice(curSeg, 1, ...newSegs);
|
|
298
|
-
curSeg = curSeg + (before ? 2 : 1); // 落到 chip 之后的可编辑段
|
|
299
|
-
curOff = 0;
|
|
300
|
-
computeFiltered();
|
|
301
|
-
redraw();
|
|
302
|
-
}
|
|
303
|
-
/** 粘贴结束:长粘贴(>8 行或 >200 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
|
|
304
|
-
function finalizePaste() {
|
|
305
|
-
if (resolved) {
|
|
306
|
-
pasteParts = [];
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
const buf = pasteParts.join('');
|
|
310
|
-
pasteParts = [];
|
|
311
|
-
justSawCR = false;
|
|
312
|
-
applyPastedText(buf);
|
|
313
|
-
}
|
|
314
|
-
/** 鼠标右键单击输入框(未拖动)时 layout 读剪贴板后回调:与键盘粘贴走同一落地逻辑(长/短判定)。 */
|
|
315
|
-
function onMousePaste(text) {
|
|
316
|
-
if (resolved)
|
|
317
|
-
return;
|
|
318
|
-
applyPastedText(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
319
|
-
}
|
|
320
|
-
/** 根据输入文本找到当前层。prefix 始终包含已进入分支及末尾空格。 */
|
|
321
|
-
function menuContext(input) {
|
|
322
|
-
let nodes = opts.commands;
|
|
323
|
-
let prefix = '';
|
|
324
|
-
while (true) {
|
|
325
|
-
const branch = nodes.find((node) => node.children?.length && input.startsWith(`${prefix}${node.name} `));
|
|
326
|
-
if (!branch?.children)
|
|
327
|
-
return { nodes, prefix };
|
|
328
|
-
prefix = `${prefix}${branch.name} `;
|
|
329
|
-
nodes = branch.children;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
function computeFiltered() {
|
|
333
|
-
const firstLine = buildDisplay().lines[0] ?? '';
|
|
334
|
-
if (firstLine.startsWith('/')) {
|
|
335
|
-
const { nodes, prefix } = menuContext(firstLine);
|
|
336
|
-
filtered = nodes
|
|
337
|
-
.map((node) => ({ node, input: `${prefix}${node.name}` }))
|
|
338
|
-
.filter((item) => item.input.startsWith(firstLine));
|
|
339
|
-
menuOpen = filtered.length > 0;
|
|
340
|
-
if (selected >= filtered.length) {
|
|
341
|
-
selected = 0;
|
|
342
|
-
menuTop = 0;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
else {
|
|
346
|
-
filtered = [];
|
|
347
|
-
menuOpen = false;
|
|
348
|
-
menuTop = 0;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
function redraw() {
|
|
352
|
-
const { lines } = buildDisplay();
|
|
353
|
-
const cur = cursorDisp();
|
|
354
|
-
// 空输入引导:缓冲为空时输入框内画 dim ghost 占位(含 /help 提示),让用户知道怎么开始;
|
|
355
|
-
// 一旦有输入(打字/粘贴/补全)isEmpty 变 false,下一帧占位即消失。
|
|
356
|
-
const isEmpty = segs.length === 1 && !segs[0].frozen && segs[0].text === '';
|
|
357
|
-
// 二次确认已武装:把提示画成菜单行(向上展开进内容区底,输入框上方),黄色 + 图标。
|
|
358
|
-
// 不复用 placeholder —— layout 只在输入行为空时画 placeholder,而武装态必然有内容。
|
|
359
|
-
const armed = confirmArmedUntil > Date.now();
|
|
360
|
-
const menu = menuLines();
|
|
361
|
-
const menuView = menu.length
|
|
362
|
-
? { lines: menu }
|
|
363
|
-
: armed
|
|
364
|
-
? { lines: [`${ui.yellow}${t('prompt.confirmSendHint')}${ui.reset}`] }
|
|
365
|
-
: null;
|
|
366
|
-
layout.paintInput({
|
|
367
|
-
prompt: opts.prompt,
|
|
368
|
-
lines,
|
|
369
|
-
cursorLine: cur.line,
|
|
370
|
-
cursorCol: cur.col,
|
|
371
|
-
menu: menuView,
|
|
372
|
-
placeholder: isEmpty ? t('prompt.emptyPlaceholder') : undefined,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
/** 鼠标点击输入框:layout 已把"屏 tap 位置"以 (flatIdx, inSegVis) 形式传进来
|
|
376
|
-
* (flatIdx = flat 中的绝对段号;inSegVis = 段内显示列)。本函数把点击位还原成 (段, 偏移) → redraw。
|
|
377
|
-
* 点中只读粘贴块时吸附到最近的可编辑边界(见 mapDispToCursor)。 */
|
|
378
|
-
function applyExternalCursor(flatIdx, inSegVis) {
|
|
379
|
-
const g = layout.getGeo();
|
|
380
|
-
const promptW = displayWidth(opts.prompt);
|
|
381
|
-
const W = Math.max(1, g.cols - promptW);
|
|
382
|
-
const { lines: dispLs } = buildDisplay();
|
|
383
|
-
const lineVis = dispLs.map((l) => wrapByDisplayWidth(l, W));
|
|
384
|
-
const flat = [];
|
|
385
|
-
for (const lv of lineVis)
|
|
386
|
-
for (const r of lv)
|
|
387
|
-
flat.push(r);
|
|
388
|
-
let dispLine;
|
|
389
|
-
let charInLine;
|
|
390
|
-
if (flat.length === 0) {
|
|
391
|
-
dispLine = 0;
|
|
392
|
-
charInLine = 0;
|
|
393
|
-
}
|
|
394
|
-
else {
|
|
395
|
-
const safeIdx = Math.max(0, Math.min(flatIdx, flat.length - 1));
|
|
396
|
-
const seg = flat[safeIdx];
|
|
397
|
-
const segW = displayWidth(seg);
|
|
398
|
-
const safeInSeg = Math.max(0, Math.min(inSegVis, segW));
|
|
399
|
-
const inChar = visColToCharCol(seg, safeInSeg);
|
|
400
|
-
// safeIdx → (dispLine, segInLine)
|
|
401
|
-
let dl = 0;
|
|
402
|
-
let segInLine = safeIdx;
|
|
403
|
-
while (dl < lineVis.length && segInLine >= lineVis[dl].length) {
|
|
404
|
-
segInLine -= lineVis[dl].length;
|
|
405
|
-
dl++;
|
|
406
|
-
}
|
|
407
|
-
if (dl >= lineVis.length) {
|
|
408
|
-
dl = lineVis.length - 1;
|
|
409
|
-
segInLine = lineVis[dl].length - 1;
|
|
410
|
-
}
|
|
411
|
-
let segStartChars = 0;
|
|
412
|
-
const segsVis = lineVis[dl];
|
|
413
|
-
for (let s = 0; s < segInLine; s++)
|
|
414
|
-
segStartChars += segsVis[s]?.length ?? 0;
|
|
415
|
-
charInLine = Math.max(0, Math.min(segStartChars + inChar, dispLs[dl]?.length ?? 0));
|
|
416
|
-
dispLine = dl;
|
|
417
|
-
}
|
|
418
|
-
const { seg, off } = mapDispToCursor(dispLine, charInLine);
|
|
419
|
-
curSeg = seg;
|
|
420
|
-
curOff = off;
|
|
421
|
-
// 收起菜单/过滤态:点击输入框关闭菜单,回到纯文本编辑。
|
|
422
|
-
if (menuOpen) {
|
|
423
|
-
menuOpen = false;
|
|
424
|
-
filtered = [];
|
|
425
|
-
selected = 0;
|
|
426
|
-
menuTop = 0;
|
|
427
|
-
}
|
|
428
|
-
justSawCR = false;
|
|
429
|
-
computeFiltered();
|
|
430
|
-
redraw();
|
|
431
|
-
}
|
|
432
|
-
function cleanup() {
|
|
433
|
-
layout.setPasteHandler(null);
|
|
434
|
-
layout.setCursorChangeHandler(null);
|
|
435
|
-
try {
|
|
436
|
-
stdin.setRawMode(false);
|
|
437
|
-
}
|
|
438
|
-
catch {
|
|
439
|
-
// 忽略
|
|
440
|
-
}
|
|
441
|
-
emitter.removeListener('keypress', onKey);
|
|
442
|
-
if (pasteTimer) {
|
|
443
|
-
clearTimeout(pasteTimer);
|
|
444
|
-
pasteTimer = null;
|
|
445
|
-
}
|
|
446
|
-
pasting = false;
|
|
447
|
-
pasteParts = [];
|
|
448
|
-
onPasteEnd = null;
|
|
449
|
-
stdin.pause();
|
|
450
|
-
}
|
|
451
|
-
function finish(value) {
|
|
452
|
-
if (resolved)
|
|
453
|
-
return;
|
|
454
|
-
resolved = true;
|
|
455
|
-
cleanup();
|
|
456
|
-
resolve(value);
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* 应用当前菜单项。分支节点只进入下一层;叶子节点补全 value/input。
|
|
460
|
-
* 返回 true 表示调用方应立即提交,false 表示仍留在输入态。
|
|
461
|
-
*/
|
|
462
|
-
function applySelected(submitLeaf) {
|
|
463
|
-
const item = filtered[selected];
|
|
464
|
-
if (!menuOpen || !item)
|
|
465
|
-
return submitLeaf;
|
|
466
|
-
if (item.node.children?.length) {
|
|
467
|
-
segs = [{ frozen: false, text: `${item.input} ` }];
|
|
468
|
-
curSeg = 0;
|
|
469
|
-
curOff = segs[0].text.length;
|
|
470
|
-
selected = 0;
|
|
471
|
-
menuTop = 0;
|
|
472
|
-
computeFiltered();
|
|
473
|
-
redraw();
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
segs = [{ frozen: false, text: item.node.value ?? item.input }];
|
|
477
|
-
curSeg = 0;
|
|
478
|
-
curOff = segs[0].text.length;
|
|
479
|
-
computeFiltered();
|
|
480
|
-
if (!submitLeaf || item.node.submit === false) {
|
|
481
|
-
redraw();
|
|
482
|
-
return false;
|
|
483
|
-
}
|
|
484
|
-
return true;
|
|
485
|
-
}
|
|
486
|
-
/** 提交:菜单打开时先应用选中项;分支进入子菜单,需要参数的叶子只补全。
|
|
487
|
-
* 段序列直接按原文(可编辑段原文 + 粘贴块原文)拼接——所见即所得,不再额外插换行。
|
|
488
|
-
*
|
|
489
|
-
* 二次确认(MOCODE_CONFIRM_SEND=long,默认关):长/多行输入首次 Enter 只"武装"并显示提示行,
|
|
490
|
-
* CONFIRM_SEND_MS 内再按一次 Enter 才真发;任何其它键都会解除武装(见 onKey 顶部)——
|
|
491
|
-
* 误触一次 Enter 发不出去,而正常发送只是多敲一下。 */
|
|
492
|
-
function submit() {
|
|
493
|
-
if (menuOpen && !applySelected(true))
|
|
494
|
-
return;
|
|
495
|
-
const content = segs.map((s) => s.text).join('');
|
|
496
|
-
const now = Date.now();
|
|
497
|
-
if (confirmSendMode() === 'long' && isLongInput(content) && now > confirmArmedUntil) {
|
|
498
|
-
confirmArmedUntil = now + CONFIRM_SEND_MS;
|
|
499
|
-
redraw();
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
confirmArmedUntil = 0;
|
|
503
|
-
finish(content === '' ? [''] : content.split('\n'));
|
|
504
|
-
}
|
|
505
|
-
/** 长输入判定:≥2 行 或 ≥120 码点(单行短消息不触发二次确认,免得打扰日常)。 */
|
|
506
|
-
function isLongInput(text) {
|
|
507
|
-
if (text.indexOf('\n') >= 0)
|
|
508
|
-
return true;
|
|
509
|
-
return [...text].length >= LONG_INPUT_CHARS;
|
|
510
|
-
}
|
|
511
|
-
/** 插换行:在光标处断行(落进当前可编辑段)。 */
|
|
512
|
-
function insertNewline() {
|
|
513
|
-
insertText('\n');
|
|
514
|
-
computeFiltered();
|
|
515
|
-
redraw();
|
|
516
|
-
}
|
|
517
|
-
/** 光标移到当前可编辑段内的行首/行尾(不含段内 \n)。 */
|
|
518
|
-
function moveToLineStart() {
|
|
519
|
-
const s = segs[curSeg].text;
|
|
520
|
-
curOff = s.lastIndexOf('\n', curOff - 1) + 1;
|
|
521
|
-
redraw();
|
|
522
|
-
}
|
|
523
|
-
function moveToLineEnd() {
|
|
524
|
-
const s = segs[curSeg].text;
|
|
525
|
-
const ne = s.indexOf('\n', curOff);
|
|
526
|
-
curOff = ne < 0 ? s.length : ne;
|
|
527
|
-
redraw();
|
|
528
|
-
}
|
|
529
|
-
/** Ctrl+U:删到行首(当前可编辑段内的当前行;已在行首则 no-op,不跨入相邻粘贴块)。 */
|
|
530
|
-
function deleteToLineStart() {
|
|
531
|
-
const s = segs[curSeg].text;
|
|
532
|
-
const ls = s.lastIndexOf('\n', curOff - 1) + 1;
|
|
533
|
-
if (ls === curOff)
|
|
534
|
-
return;
|
|
535
|
-
segs[curSeg].text = s.slice(0, ls) + s.slice(curOff);
|
|
536
|
-
curOff = ls;
|
|
537
|
-
computeFiltered();
|
|
538
|
-
redraw();
|
|
539
|
-
}
|
|
540
|
-
/** Ctrl+K:删到行尾(当前可编辑段内的当前行;已在行尾则 no-op,不跨入相邻粘贴块)。 */
|
|
541
|
-
function deleteToLineEnd() {
|
|
542
|
-
const s = segs[curSeg].text;
|
|
543
|
-
const ne = s.indexOf('\n', curOff);
|
|
544
|
-
if (ne < 0) {
|
|
545
|
-
if (curOff === s.length)
|
|
546
|
-
return;
|
|
547
|
-
segs[curSeg].text = s.slice(0, curOff);
|
|
548
|
-
}
|
|
549
|
-
else {
|
|
550
|
-
segs[curSeg].text = s.slice(0, curOff) + s.slice(ne + 1);
|
|
551
|
-
}
|
|
552
|
-
computeFiltered();
|
|
553
|
-
redraw();
|
|
554
|
-
}
|
|
555
|
-
/** Ctrl+Z:还原被 Ctrl+C 清掉的输入(单次)。 */
|
|
556
|
-
function restoreUndo() {
|
|
557
|
-
const snap = undoSnapshot;
|
|
558
|
-
if (!snap)
|
|
559
|
-
return;
|
|
560
|
-
segs = snap.segs.map((s) => ({ ...s }));
|
|
561
|
-
curSeg = Math.min(snap.curSeg, segs.length - 1);
|
|
562
|
-
curOff = Math.min(snap.curOff, segs[curSeg].text.length);
|
|
563
|
-
undoSnapshot = null;
|
|
564
|
-
computeFiltered();
|
|
565
|
-
redraw();
|
|
566
|
-
}
|
|
567
|
-
/**
|
|
568
|
-
* 清空输入(含粘贴块 / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。
|
|
569
|
-
* 清空前留一份快照供 Ctrl+Z 还原——手滑按在输入框上时,正在写的长 prompt 不至于白打。
|
|
570
|
-
*/
|
|
571
|
-
function clearInput() {
|
|
572
|
-
if (layout.isScrolled())
|
|
573
|
-
layout.resetScroll(); // 回尾(若滚动回看),再清空
|
|
574
|
-
undoSnapshot = { segs: segs.map((s) => ({ ...s })), curSeg, curOff };
|
|
575
|
-
segs = [{ frozen: false, text: '' }];
|
|
576
|
-
curSeg = 0;
|
|
577
|
-
curOff = 0;
|
|
578
|
-
menuOpen = false;
|
|
579
|
-
filtered = [];
|
|
580
|
-
selected = 0;
|
|
581
|
-
menuTop = 0;
|
|
582
|
-
if (pasteTimer) {
|
|
583
|
-
clearTimeout(pasteTimer);
|
|
584
|
-
pasteTimer = null;
|
|
585
|
-
}
|
|
586
|
-
pasting = false;
|
|
587
|
-
pasteParts = [];
|
|
588
|
-
justSawCR = false;
|
|
589
|
-
computeFiltered();
|
|
590
|
-
redraw();
|
|
591
|
-
}
|
|
592
|
-
/** Ctrl+C:有内容(已打字 / 多行 / 粘贴块 / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish)。 */
|
|
593
|
-
function onCtrlC() {
|
|
594
|
-
const flat = segs.map((s) => s.text).join('');
|
|
595
|
-
const hasContent = flat.length > 0 || menuOpen || pasteParts.length > 0 || pasting;
|
|
596
|
-
if (hasContent) {
|
|
597
|
-
clearInput();
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
cleanup();
|
|
601
|
-
reject(new Error('SIGINT'));
|
|
602
|
-
}
|
|
603
|
-
function onKey(_str, key) {
|
|
604
|
-
if (resolved || !key)
|
|
605
|
-
return;
|
|
606
|
-
// 鼠标 fragment(SGR 报表被 readline 拆碎):mouse.swallow 重组并派发 MouseEvent 给
|
|
607
|
-
// layout.handleMouseEvent(滚轮/框选/复制全在那处理);此处只需吞掉 fragment 不进 pasteParts/输入框。
|
|
608
|
-
if (mouse.swallow(key.sequence ?? ''))
|
|
609
|
-
return;
|
|
610
|
-
// 二次确认已武装:任何非「提交键」都解除武装(继续编辑 / 换行 / 删除都算放弃这次提交)。
|
|
611
|
-
if (confirmArmedUntil) {
|
|
612
|
-
const isSubmitKey = (key.name === 'return' || key.name === 'enter') && !key.shift && !key.meta && !key.ctrl;
|
|
613
|
-
if (!isSubmitKey) {
|
|
614
|
-
confirmArmedUntil = 0;
|
|
615
|
-
redraw();
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
// Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
|
|
619
|
-
// 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
|
|
620
|
-
// paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
|
|
621
|
-
if (key.shift && key.name === 'tab') {
|
|
622
|
-
opts.onCycleMode?.();
|
|
623
|
-
redraw();
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
// Ctrl+C:有内容则清空,再按一次(空)才退出——置顶,使粘贴中也能被截到(否则被 pasting 分支吞掉)
|
|
627
|
-
if (key.ctrl && key.name === 'c') {
|
|
628
|
-
onCtrlC();
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
// 粘贴中:把键累积进 pasteParts(换行 \r\n 合一),不编辑 lines;末尾 finalizePaste 统一落 chip/文本
|
|
632
|
-
if (pasting) {
|
|
633
|
-
const s = key.sequence ?? '';
|
|
634
|
-
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
635
|
-
if (s === '\n' && justSawCR) {
|
|
636
|
-
justSawCR = false; // \r\n 的 \n:已随 \r 折行,吞掉
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
if (isReturn || s === '\r' || s === '\n') {
|
|
640
|
-
justSawCR = s === '\r'; // \r 标记,待可能的尾随 \n
|
|
641
|
-
pasteParts.push('\n');
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
justSawCR = false;
|
|
645
|
-
// FEFF(BOM 字符)过滤:其他应用写入剪贴板的正文可能带 BOM,终端级粘贴逐字符灌入时
|
|
646
|
-
// 会敲进输入框(charWidth 按 1 列算但终端渲染成缺字形方块)
|
|
647
|
-
if (s && s >= ' ' && s !== '\uFEFF' && !key.ctrl && !key.meta)
|
|
648
|
-
pasteParts.push(s);
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
|
|
652
|
-
// 裸 ↑/↓ 优先给斜杠菜单与多行光标,二者都不适用时按滚轮量滚动回看(与鼠标滚轮同一入口)。
|
|
653
|
-
if (key.name === 'pageup' ||
|
|
654
|
-
key.name === 'pagedown' ||
|
|
655
|
-
(key.ctrl && (key.name === 'up' || key.name === 'down'))) {
|
|
656
|
-
const pageH = layout.getGeo().contentBottom;
|
|
657
|
-
if (key.name === 'pageup')
|
|
658
|
-
layout.scrollBy(pageH);
|
|
659
|
-
else if (key.name === 'pagedown')
|
|
660
|
-
layout.scrollBy(-pageH);
|
|
661
|
-
else if (key.name === 'up')
|
|
662
|
-
layout.scrollBy(5);
|
|
663
|
-
else
|
|
664
|
-
layout.scrollBy(-5);
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
// 滚动回看时打字 / 编辑不回尾(保持历史视图,便于边看历史边编辑);仅 Enter 提交时回尾(见下方 submit 前)。
|
|
668
|
-
// Ctrl+C 已在 onKey 顶部统一处理(清空或退出);Ctrl+D:空缓冲退出,否则忽略
|
|
669
|
-
if (key.ctrl && key.name === 'd') {
|
|
670
|
-
if (segs.map((s) => s.text).join('') === '')
|
|
671
|
-
finish(null);
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
if (key.ctrl && key.name === 'a') {
|
|
675
|
-
moveToLineStart();
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
if (key.ctrl && key.name === 'q') {
|
|
679
|
-
// 行首的对称搭档:与 Ctrl+E(行尾)同处顶行相邻(Q/E),左=起始右=结束。
|
|
680
|
-
// Ctrl+A 仍作别名保留(全 app 一致:运行中预填、权限面板都用 Ctrl+A 跳开头)。
|
|
681
|
-
moveToLineStart();
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
if (key.ctrl && key.name === 'e') {
|
|
685
|
-
moveToLineEnd();
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
// Ctrl+Z:还原被 Ctrl+C 清掉的输入。无快照时 no-op。
|
|
689
|
-
if (key.ctrl && key.name === 'z') {
|
|
690
|
-
restoreUndo();
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
// 行级删除:Ctrl+U 删到行首,Ctrl+K 删到行尾(均在当前可编辑段内,不跨入相邻粘贴块)。
|
|
694
|
-
// 词级删除/跳转(Ctrl+W / Ctrl+← 等)刻意不做:终端序列各终端发得不一样
|
|
695
|
-
// (Ctrl+Backspace 在多数终端发 \x08,与裸退格无从区分),做一半会变成按了没反应的哑键。
|
|
696
|
-
if (key.ctrl && key.name === 'u') {
|
|
697
|
-
deleteToLineStart();
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
if (key.ctrl && key.name === 'k') {
|
|
701
|
-
deleteToLineEnd();
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
// Ctrl+G:打开输入面板弹窗(记事本式编辑,Enter=换行不发送;确认只填回不发送)。
|
|
705
|
-
if (key.ctrl && key.name === 'g') {
|
|
706
|
-
void openComposer();
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
// Ctrl+R / Ctrl+P:历史模糊搜索。Ctrl+R 对齐 bash 反向搜索语义,Ctrl+P 对齐 previous-history 语义,
|
|
710
|
-
// 两者是同一个面板——记住哪个都行。
|
|
711
|
-
if (key.ctrl && (key.name === 'r' || key.name === 'p')) {
|
|
712
|
-
void openHistorySearch();
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
716
|
-
// 换行:Ctrl+J / Ctrl+Enter / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
|
|
717
|
-
// (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
|
|
718
|
-
// Ctrl+Enter:readline 解析得到 key.ctrl=true && isReturn 走这条路;少数老 xterm 把
|
|
719
|
-
// Ctrl+Enter 当裸 \r 发(没 ctrl flag)→ 落到下方「提交」分支做 Enter 处理,
|
|
720
|
-
// 此时用 Ctrl+J 兜底。
|
|
721
|
-
const wantNewline = (key.ctrl && key.name === 'j') ||
|
|
722
|
-
(key.ctrl && isReturn) ||
|
|
723
|
-
(key.meta && isReturn) ||
|
|
724
|
-
(key.shift && isReturn) ||
|
|
725
|
-
(key.sequence === '\n' && !key.ctrl);
|
|
726
|
-
if (wantNewline) {
|
|
727
|
-
insertNewline();
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
// 提交(键盘 plain Enter):回尾(若滚动回看)再发送——发消息时跳到底部开始 agent 输出
|
|
731
|
-
if (isReturn && !key.shift && !key.meta && !key.ctrl) {
|
|
732
|
-
if (layout.isScrolled())
|
|
733
|
-
layout.resetScroll();
|
|
734
|
-
submit();
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
switch (key.name) {
|
|
738
|
-
case 'backspace': {
|
|
739
|
-
const s = segs[curSeg].text;
|
|
740
|
-
if (curOff > 0) {
|
|
741
|
-
segs[curSeg].text = s.slice(0, curOff - 1) + s.slice(curOff);
|
|
742
|
-
curOff--;
|
|
743
|
-
computeFiltered();
|
|
744
|
-
redraw();
|
|
745
|
-
}
|
|
746
|
-
else if (curSeg > 0) {
|
|
747
|
-
const prev = segs[curSeg - 1];
|
|
748
|
-
if (prev.frozen) {
|
|
749
|
-
// 紧贴粘贴块后沿退格:删整块(原子)
|
|
750
|
-
segs.splice(curSeg - 1, 1);
|
|
751
|
-
curSeg--;
|
|
752
|
-
curOff = 0;
|
|
753
|
-
}
|
|
754
|
-
else {
|
|
755
|
-
// 并回上一个可编辑段
|
|
756
|
-
prev.text += segs[curSeg].text;
|
|
757
|
-
const joinOff = prev.text.length - segs[curSeg].text.length;
|
|
758
|
-
segs.splice(curSeg, 1);
|
|
759
|
-
curSeg--;
|
|
760
|
-
curOff = joinOff;
|
|
761
|
-
}
|
|
762
|
-
computeFiltered();
|
|
763
|
-
redraw();
|
|
764
|
-
}
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
case 'delete': {
|
|
768
|
-
const s = segs[curSeg].text;
|
|
769
|
-
if (curOff < s.length) {
|
|
770
|
-
segs[curSeg].text = s.slice(0, curOff) + s.slice(curOff + 1);
|
|
771
|
-
computeFiltered();
|
|
772
|
-
redraw();
|
|
773
|
-
}
|
|
774
|
-
else if (curSeg < segs.length - 1) {
|
|
775
|
-
const next = segs[curSeg + 1];
|
|
776
|
-
if (next.frozen)
|
|
777
|
-
segs.splice(curSeg + 1, 1); // 并掉下一个粘贴块
|
|
778
|
-
else {
|
|
779
|
-
segs[curSeg].text += next.text;
|
|
780
|
-
segs.splice(curSeg + 1, 1);
|
|
781
|
-
}
|
|
782
|
-
computeFiltered();
|
|
783
|
-
redraw();
|
|
784
|
-
}
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
case 'up':
|
|
788
|
-
if (menuOpen && filtered.length) {
|
|
789
|
-
selected = (selected - 1 + filtered.length) % filtered.length;
|
|
790
|
-
redraw();
|
|
791
|
-
}
|
|
792
|
-
else {
|
|
793
|
-
const { line, col } = cursorDisp();
|
|
794
|
-
if (line > 0) {
|
|
795
|
-
const m = mapDispToCursor(line - 1, dispColToCharInLine(line - 1, col));
|
|
796
|
-
curSeg = m.seg;
|
|
797
|
-
curOff = m.off;
|
|
798
|
-
redraw();
|
|
799
|
-
}
|
|
800
|
-
else {
|
|
801
|
-
layout.scrollWheel(1); // 等同鼠标滚轮上滚一格
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
return;
|
|
805
|
-
case 'down':
|
|
806
|
-
if (menuOpen && filtered.length) {
|
|
807
|
-
selected = (selected + 1) % filtered.length;
|
|
808
|
-
redraw();
|
|
809
|
-
}
|
|
810
|
-
else {
|
|
811
|
-
const { lines: dispLines } = buildDisplay();
|
|
812
|
-
const { line, col } = cursorDisp();
|
|
813
|
-
if (line < dispLines.length - 1) {
|
|
814
|
-
const m = mapDispToCursor(line + 1, dispColToCharInLine(line + 1, col));
|
|
815
|
-
curSeg = m.seg;
|
|
816
|
-
curOff = m.off;
|
|
817
|
-
redraw();
|
|
818
|
-
}
|
|
819
|
-
else {
|
|
820
|
-
layout.scrollWheel(-1); // 等同鼠标滚轮下滚一格
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
return;
|
|
824
|
-
case 'tab':
|
|
825
|
-
if (menuOpen && filtered[selected])
|
|
826
|
-
applySelected(false);
|
|
827
|
-
return;
|
|
828
|
-
case 'escape': {
|
|
829
|
-
const { prefix } = menuContext(buildDisplay().lines[0] ?? '');
|
|
830
|
-
if (prefix) {
|
|
831
|
-
// 子层 Esc 回到父节点;例如 /model sw → /model。
|
|
832
|
-
segs = [{ frozen: false, text: prefix.trimEnd() }];
|
|
833
|
-
curSeg = 0;
|
|
834
|
-
curOff = segs[0].text.length;
|
|
835
|
-
selected = 0;
|
|
836
|
-
menuTop = 0;
|
|
837
|
-
computeFiltered();
|
|
838
|
-
}
|
|
839
|
-
else {
|
|
840
|
-
menuOpen = false;
|
|
841
|
-
filtered = [];
|
|
842
|
-
selected = 0;
|
|
843
|
-
menuTop = 0;
|
|
844
|
-
}
|
|
845
|
-
redraw();
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
case 'left': {
|
|
849
|
-
if (curOff > 0) {
|
|
850
|
-
curOff--;
|
|
851
|
-
redraw();
|
|
852
|
-
}
|
|
853
|
-
else if (curSeg > 0) {
|
|
854
|
-
let p = curSeg - 1;
|
|
855
|
-
while (p >= 0 && segs[p].frozen)
|
|
856
|
-
p--;
|
|
857
|
-
if (p >= 0) {
|
|
858
|
-
curSeg = p;
|
|
859
|
-
curOff = segs[p].text.length;
|
|
860
|
-
redraw();
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
case 'right': {
|
|
866
|
-
const s = segs[curSeg].text;
|
|
867
|
-
if (curOff < s.length) {
|
|
868
|
-
curOff++;
|
|
869
|
-
redraw();
|
|
870
|
-
}
|
|
871
|
-
else if (curSeg < segs.length - 1) {
|
|
872
|
-
let nx = curSeg + 1;
|
|
873
|
-
while (nx < segs.length && segs[nx].frozen)
|
|
874
|
-
nx++;
|
|
875
|
-
if (nx < segs.length) {
|
|
876
|
-
curSeg = nx;
|
|
877
|
-
curOff = 0;
|
|
878
|
-
redraw();
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
case 'home':
|
|
884
|
-
moveToLineStart();
|
|
885
|
-
return;
|
|
886
|
-
case 'end':
|
|
887
|
-
moveToLineEnd();
|
|
888
|
-
return;
|
|
889
|
-
}
|
|
890
|
-
// 可打印字符(>= 空格,非 ctrl/meta)
|
|
891
|
-
const s = key.sequence ?? '';
|
|
892
|
-
if (s && s >= ' ' && !key.ctrl && !key.meta) {
|
|
893
|
-
insertText(s);
|
|
894
|
-
computeFiltered();
|
|
895
|
-
redraw();
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
/**
|
|
899
|
-
* Ctrl+G:打开 TUI 内「输入面板」弹窗(记事本式多行编辑,**不外调 $EDITOR**)。
|
|
900
|
-
* 弹窗里 Enter 就是换行,不会再触发发送;确认(Ctrl+S)只把内容填回输入框,不自动发送。
|
|
901
|
-
* 期间必须摘掉主 keypress 监听:弹窗关闭后终端可能残留按键事件,不摘会被当成输入吞掉。
|
|
902
|
-
*/
|
|
903
|
-
async function openComposer() {
|
|
904
|
-
if (resolved)
|
|
905
|
-
return;
|
|
906
|
-
const before = segs.map((s) => s.text).join('');
|
|
907
|
-
emitter.removeListener('keypress', onKey);
|
|
908
|
-
let result;
|
|
909
|
-
try {
|
|
910
|
-
result = await promptComposer({ initialText: before });
|
|
911
|
-
}
|
|
912
|
-
finally {
|
|
913
|
-
emitter.on('keypress', onKey);
|
|
914
|
-
stdin.resume();
|
|
915
|
-
// 弹窗是直写覆盖层,关闭后整幅重画还原 content 区
|
|
916
|
-
layout.repaintViewport();
|
|
917
|
-
}
|
|
918
|
-
if (resolved)
|
|
919
|
-
return; // 极端路径:弹窗期间被 finish
|
|
920
|
-
if (result.text != null && result.text !== before) {
|
|
921
|
-
undoSnapshot = { segs: segs.map((s) => ({ ...s })), curSeg, curOff }; // Ctrl+Z 可还原回弹窗前
|
|
922
|
-
segs = [{ frozen: false, text: result.text }];
|
|
923
|
-
curSeg = 0;
|
|
924
|
-
curOff = segs[0].text.length;
|
|
925
|
-
}
|
|
926
|
-
computeFiltered();
|
|
927
|
-
redraw();
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* Ctrl+R / Ctrl+P:历史模糊搜索面板。
|
|
931
|
-
* Enter 只把选中项**填回输入框**(安全网:找回来的长 prompt 通常还要改),Ctrl+Enter 才直接发送。
|
|
932
|
-
*/
|
|
933
|
-
async function openHistorySearch() {
|
|
934
|
-
if (resolved || !opts.history)
|
|
935
|
-
return;
|
|
936
|
-
// 惰性求值:跨会话聚合要读盘,只在面板真正打开时做
|
|
937
|
-
const historyItems = typeof opts.history === 'function' ? opts.history() : opts.history;
|
|
938
|
-
if (historyItems.length === 0)
|
|
939
|
-
return;
|
|
940
|
-
const before = segs.map((s) => s.text).join('');
|
|
941
|
-
emitter.removeListener('keypress', onKey);
|
|
942
|
-
let picked = null;
|
|
943
|
-
try {
|
|
944
|
-
picked = await promptHistorySearch({ items: historyItems, initialQuery: before });
|
|
945
|
-
}
|
|
946
|
-
finally {
|
|
947
|
-
emitter.on('keypress', onKey);
|
|
948
|
-
stdin.resume();
|
|
949
|
-
}
|
|
950
|
-
if (resolved)
|
|
951
|
-
return;
|
|
952
|
-
if (picked) {
|
|
953
|
-
segs = [{ frozen: false, text: picked.text }];
|
|
954
|
-
curSeg = 0;
|
|
955
|
-
curOff = segs[0].text.length;
|
|
956
|
-
computeFiltered();
|
|
957
|
-
if (picked.send) {
|
|
958
|
-
submit();
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
redraw();
|
|
963
|
-
}
|
|
964
|
-
return new Promise((res, rej) => {
|
|
965
|
-
resolve = res;
|
|
966
|
-
reject = rej;
|
|
967
|
-
// 清掉上一轮可能残留的粘贴状态:子面板(历史搜索)不装 paste sink,粘贴的字会逐字符进它的输入框,
|
|
968
|
-
// 但共享的 pasteParts 不会有人消费 —— 残留会让 Ctrl+C 的"有内容"判定误判。
|
|
969
|
-
if (pasteTimer) {
|
|
970
|
-
clearTimeout(pasteTimer);
|
|
971
|
-
pasteTimer = null;
|
|
972
|
-
}
|
|
973
|
-
pasting = false;
|
|
974
|
-
pasteParts = [];
|
|
975
|
-
justSawCR = false;
|
|
976
|
-
ensurePasteDetector(); // 首次调用在 emitKeypressEvents 之前装 data 监听器(保序:mine 先于 解析器)
|
|
977
|
-
onPasteEnd = finalizePaste; // 粘贴结束回调:落 chip 或保留文本
|
|
978
|
-
readline.emitKeypressEvents(stdin);
|
|
979
|
-
let rawOk = true;
|
|
980
|
-
try {
|
|
981
|
-
stdin.setRawMode(true);
|
|
982
|
-
}
|
|
983
|
-
catch {
|
|
984
|
-
rawOk = false;
|
|
985
|
-
}
|
|
986
|
-
if (!rawOk) {
|
|
987
|
-
// isTTY 但 setRawMode 失败(罕见):退化为 readline,不注册 setPasteHandler(不走 cleanup,防泄漏)
|
|
988
|
-
questionFallback(opts.prompt).then((a) => res([a]), (e) => rej(e));
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
layout.setPasteHandler(onMousePaste); // 鼠标右键单击输入框(未拖动)→ 读剪贴板贴入;cleanup 时注销
|
|
992
|
-
layout.setCursorChangeHandler(applyExternalCursor); // 鼠标左键单击输入框(未拖动)→ 改 cl/cc 到点击位;cleanup 时注销
|
|
993
|
-
stdin.resume();
|
|
994
|
-
emitter.on('keypress', onKey);
|
|
995
|
-
computeFiltered();
|
|
996
|
-
redraw();
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
/**
|
|
1000
|
-
* 轮次选择菜单(供 /rollback 菜单化选择):↑/↓ 导航、Enter 选中、Esc/Ctrl+D 取消。
|
|
1001
|
-
* 把 items 画成向上展开的菜单(经 layout.paintInput,与斜杠菜单同套渲染),输入框行作操作提示。
|
|
1002
|
-
* 返回选中的 0-based 下标;null=取消 / 非 TTY / 空列表。纯导航(不收文本输入)。
|
|
1003
|
-
* 长列表(超屏高)自动开窗保光标可见;默认聚焦末项(最新轮次,靠近输入框)。
|
|
1004
|
-
*/
|
|
1005
|
-
export async function promptTurnPicker(items) {
|
|
1006
|
-
if (!layout.isActive() || items.length === 0)
|
|
1007
|
-
return null;
|
|
1008
|
-
const emitter = stdin;
|
|
1009
|
-
const hint = t('prompt.chooseRollback');
|
|
1010
|
-
let selected = items.length - 1; // 默认聚焦最新(末项,菜单底、靠近输入框)
|
|
1011
|
-
let resolved = false;
|
|
1012
|
-
let resolve;
|
|
1013
|
-
let reject;
|
|
1014
|
-
/** 菜单行(带开窗):超屏高时以 selected 为中心取窗,保光标可见;末项在底(靠近输入框)。 */
|
|
1015
|
-
function menuLines() {
|
|
1016
|
-
const g = layout.getGeo();
|
|
1017
|
-
const maxRows = Math.max(1, g.contentBottom);
|
|
1018
|
-
let start = 0;
|
|
1019
|
-
if (items.length > maxRows) {
|
|
1020
|
-
start = Math.max(0, Math.min(selected - Math.floor(maxRows / 2), items.length - maxRows));
|
|
1021
|
-
}
|
|
1022
|
-
const count = Math.min(maxRows, items.length);
|
|
1023
|
-
const cols = g.cols;
|
|
1024
|
-
return Array.from({ length: count }, (_, i) => {
|
|
1025
|
-
const idx = start + i;
|
|
1026
|
-
// 选中项:▸/序号/正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
1027
|
-
const isSel = idx === selected;
|
|
1028
|
-
const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
|
|
1029
|
-
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
1030
|
-
const num = `${color}${idx + 1}${ui.reset}`;
|
|
1031
|
-
const text = truncateDisplay(items[idx].firstLine, cols - 6);
|
|
1032
|
-
return `${marker} ${num} ${color}${text}${ui.reset}`;
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
function redraw() {
|
|
1036
|
-
layout.paintInput({
|
|
1037
|
-
prompt: '❯ ',
|
|
1038
|
-
lines: [hint],
|
|
1039
|
-
cursorLine: 0,
|
|
1040
|
-
cursorCol: displayWidth(hint),
|
|
1041
|
-
menu: { lines: menuLines() },
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
function cleanup() {
|
|
1045
|
-
layout.setMouseEnabled(true); // 恢复鼠标框选/滚轮(面板期间被禁,防拖拽覆盖菜单)
|
|
1046
|
-
try {
|
|
1047
|
-
stdin.setRawMode(false);
|
|
1048
|
-
}
|
|
1049
|
-
catch {
|
|
1050
|
-
// 忽略
|
|
1051
|
-
}
|
|
1052
|
-
emitter.removeListener('keypress', onKey);
|
|
1053
|
-
stdin.pause();
|
|
1054
|
-
}
|
|
1055
|
-
function finish(value) {
|
|
1056
|
-
if (resolved)
|
|
1057
|
-
return;
|
|
1058
|
-
resolved = true;
|
|
1059
|
-
cleanup();
|
|
1060
|
-
resolve(value);
|
|
1061
|
-
}
|
|
1062
|
-
function onKey(_str, key) {
|
|
1063
|
-
if (resolved || !key)
|
|
1064
|
-
return;
|
|
1065
|
-
if (mouse.swallow(key.sequence ?? ''))
|
|
1066
|
-
return; // 鼠标 fragment 吞掉(框选已禁,滚轮 handleMouseEvent no-op)
|
|
1067
|
-
if (key.ctrl && key.name === 'c') {
|
|
1068
|
-
cleanup();
|
|
1069
|
-
reject(new Error('SIGINT'));
|
|
1070
|
-
return;
|
|
1071
|
-
}
|
|
1072
|
-
if (key.ctrl && key.name === 'd') {
|
|
1073
|
-
finish(null);
|
|
1074
|
-
return;
|
|
1075
|
-
}
|
|
1076
|
-
switch (key.name) {
|
|
1077
|
-
case 'up':
|
|
1078
|
-
selected = (selected - 1 + items.length) % items.length;
|
|
1079
|
-
redraw();
|
|
1080
|
-
return;
|
|
1081
|
-
case 'down':
|
|
1082
|
-
selected = (selected + 1) % items.length;
|
|
1083
|
-
redraw();
|
|
1084
|
-
return;
|
|
1085
|
-
case 'return':
|
|
1086
|
-
case 'enter':
|
|
1087
|
-
finish(selected);
|
|
1088
|
-
return;
|
|
1089
|
-
case 'escape':
|
|
1090
|
-
finish(null);
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
return new Promise((res, rej) => {
|
|
1095
|
-
resolve = res;
|
|
1096
|
-
reject = rej;
|
|
1097
|
-
layout.setMouseEnabled(false); // 面板期间禁鼠标框选/滚轮(防拖拽 viewport 重画覆盖菜单)
|
|
1098
|
-
ensurePasteDetector();
|
|
1099
|
-
readline.emitKeypressEvents(stdin);
|
|
1100
|
-
let rawOk = true;
|
|
1101
|
-
try {
|
|
1102
|
-
stdin.setRawMode(true);
|
|
1103
|
-
}
|
|
1104
|
-
catch {
|
|
1105
|
-
rawOk = false;
|
|
1106
|
-
}
|
|
1107
|
-
if (!rawOk) {
|
|
1108
|
-
res(null);
|
|
1109
|
-
return;
|
|
1110
|
-
}
|
|
1111
|
-
stdin.resume();
|
|
1112
|
-
emitter.on('keypress', onKey);
|
|
1113
|
-
redraw();
|
|
1114
|
-
});
|
|
1115
|
-
}
|
|
1116
|
-
export async function promptSessionPicker(items, recentCap = 10) {
|
|
1117
|
-
if (!layout.isActive() || items.length === 0)
|
|
1118
|
-
return null;
|
|
1119
|
-
const emitter = stdin;
|
|
1120
|
-
const cap = Math.max(1, recentCap);
|
|
1121
|
-
const canToggle = items.length > cap; // 不超过 cap 时无切换意义(本就全显)
|
|
1122
|
-
let showAll = !canToggle; // 超过 cap 才默认折叠到最近 N,否则全显
|
|
1123
|
-
let selected = 0; // 默认聚焦首项(调用方降序传入 → 最新会话)
|
|
1124
|
-
let resolved = false;
|
|
1125
|
-
let resolve;
|
|
1126
|
-
let reject;
|
|
1127
|
-
/** 当前可见项:折叠态取前 cap 条(=最近 N),展开态取全部。 */
|
|
1128
|
-
function visible() {
|
|
1129
|
-
return showAll ? items : items.slice(0, cap);
|
|
1130
|
-
}
|
|
1131
|
-
/** 输入框行的操作提示;折叠态显「a 全部(N)」,展开态显「a 仅最近N」。不超 cap 时无 a 项。 */
|
|
1132
|
-
function hint() {
|
|
1133
|
-
const base = t('prompt.chooseResume');
|
|
1134
|
-
if (!canToggle)
|
|
1135
|
-
return base;
|
|
1136
|
-
return showAll
|
|
1137
|
-
? t('prompt.recent', { count: cap })
|
|
1138
|
-
: t('prompt.all', { count: items.length });
|
|
1139
|
-
}
|
|
1140
|
-
/** 菜单行(带开窗):超屏高时以 selected 为中心取窗,保光标可见。行格式:▸ N title subtitle。 */
|
|
1141
|
-
function menuLines() {
|
|
1142
|
-
const g = layout.getGeo();
|
|
1143
|
-
const cols = g.cols;
|
|
1144
|
-
const maxRows = Math.max(1, g.contentBottom);
|
|
1145
|
-
const vis = visible();
|
|
1146
|
-
let start = 0;
|
|
1147
|
-
if (vis.length > maxRows) {
|
|
1148
|
-
start = Math.max(0, Math.min(selected - Math.floor(maxRows / 2), vis.length - maxRows));
|
|
1149
|
-
}
|
|
1150
|
-
const count = Math.min(maxRows, vis.length);
|
|
1151
|
-
return Array.from({ length: count }, (_, i) => {
|
|
1152
|
-
const idx = start + i;
|
|
1153
|
-
// 选中项:▸/序号/正文/副标题均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
1154
|
-
const isSel = idx === selected;
|
|
1155
|
-
const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
|
|
1156
|
-
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
1157
|
-
const num = String(idx + 1);
|
|
1158
|
-
const it = vis[idx];
|
|
1159
|
-
const title = it.title || '(无)';
|
|
1160
|
-
const sub = it.subtitle ?? '';
|
|
1161
|
-
const leadW = displayWidth(num) + 4; // "▸ " + num + " "
|
|
1162
|
-
let subW = sub ? displayWidth(sub) + 2 : 0; // " " + sub
|
|
1163
|
-
let titleW = cols - leadW - subW;
|
|
1164
|
-
if (titleW < 4 && sub) {
|
|
1165
|
-
// 太窄:先丢副标题把空间让给标题
|
|
1166
|
-
subW = 0;
|
|
1167
|
-
titleW = cols - leadW;
|
|
1168
|
-
}
|
|
1169
|
-
const titleT = titleW > 0 ? truncateDisplay(title, titleW) : '';
|
|
1170
|
-
const subPart = subW > 0 ? ` ${sub}` : '';
|
|
1171
|
-
return `${marker} ${color}${num} ${titleT}${subPart}${ui.reset}`;
|
|
1172
|
-
});
|
|
1173
|
-
}
|
|
1174
|
-
function redraw() {
|
|
1175
|
-
const h = hint();
|
|
1176
|
-
layout.paintInput({
|
|
1177
|
-
prompt: '❯ ',
|
|
1178
|
-
lines: [h],
|
|
1179
|
-
cursorLine: 0,
|
|
1180
|
-
cursorCol: displayWidth(h),
|
|
1181
|
-
menu: { lines: menuLines() },
|
|
1182
|
-
});
|
|
1183
|
-
}
|
|
1184
|
-
function cleanup() {
|
|
1185
|
-
layout.setMouseEnabled(true);
|
|
1186
|
-
try {
|
|
1187
|
-
stdin.setRawMode(false);
|
|
1188
|
-
}
|
|
1189
|
-
catch {
|
|
1190
|
-
// 忽略
|
|
1191
|
-
}
|
|
1192
|
-
emitter.removeListener('keypress', onKey);
|
|
1193
|
-
stdin.pause();
|
|
1194
|
-
}
|
|
1195
|
-
function finish(value) {
|
|
1196
|
-
if (resolved)
|
|
1197
|
-
return;
|
|
1198
|
-
resolved = true;
|
|
1199
|
-
cleanup();
|
|
1200
|
-
resolve(value);
|
|
1201
|
-
}
|
|
1202
|
-
function onKey(_str, key) {
|
|
1203
|
-
if (resolved || !key)
|
|
1204
|
-
return;
|
|
1205
|
-
if (mouse.swallow(key.sequence ?? ''))
|
|
1206
|
-
return;
|
|
1207
|
-
if (key.ctrl && key.name === 'c') {
|
|
1208
|
-
cleanup();
|
|
1209
|
-
reject(new Error('SIGINT'));
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
if (key.ctrl && key.name === 'd') {
|
|
1213
|
-
finish(null);
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
// a 切换「仅最近 N / 全部」:纯导航菜单无文本输入,a 自由;key.name 不分大小写,Shift+A 亦触发。
|
|
1217
|
-
if (canToggle && key.name === 'a' && !key.ctrl && !key.meta) {
|
|
1218
|
-
showAll = !showAll;
|
|
1219
|
-
const vis = visible();
|
|
1220
|
-
if (selected > vis.length - 1)
|
|
1221
|
-
selected = vis.length - 1; // 折回 cap 时选中项越界则钳到末项
|
|
1222
|
-
redraw();
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
const n = visible().length;
|
|
1226
|
-
switch (key.name) {
|
|
1227
|
-
case 'up':
|
|
1228
|
-
selected = (selected - 1 + n) % n;
|
|
1229
|
-
redraw();
|
|
1230
|
-
return;
|
|
1231
|
-
case 'down':
|
|
1232
|
-
selected = (selected + 1) % n;
|
|
1233
|
-
redraw();
|
|
1234
|
-
return;
|
|
1235
|
-
case 'return':
|
|
1236
|
-
case 'enter':
|
|
1237
|
-
finish(visible()[selected] ?? null);
|
|
1238
|
-
return;
|
|
1239
|
-
case 'escape':
|
|
1240
|
-
finish(null);
|
|
1241
|
-
return;
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
return new Promise((res, rej) => {
|
|
1245
|
-
resolve = res;
|
|
1246
|
-
reject = rej;
|
|
1247
|
-
layout.setMouseEnabled(false);
|
|
1248
|
-
ensurePasteDetector();
|
|
1249
|
-
readline.emitKeypressEvents(stdin);
|
|
1250
|
-
let rawOk = true;
|
|
1251
|
-
try {
|
|
1252
|
-
stdin.setRawMode(true);
|
|
1253
|
-
}
|
|
1254
|
-
catch {
|
|
1255
|
-
rawOk = false;
|
|
1256
|
-
}
|
|
1257
|
-
if (!rawOk) {
|
|
1258
|
-
res(null);
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
stdin.resume();
|
|
1262
|
-
emitter.on('keypress', onKey);
|
|
1263
|
-
redraw();
|
|
1264
|
-
});
|
|
1265
|
-
}
|
|
1266
|
-
/**
|
|
1267
|
-
* 主题选择菜单(供 /theme):↑/↓ 导航、Enter 切换、Esc/Ctrl+D 取消、Ctrl+C 抛 SIGINT(调用方 try/catch)。
|
|
1268
|
-
* 复用 SessionPickerItem 形状({id,title,subtitle?});选中项 cyan+bold + ▸ 高亮(同 /resume 菜单)。
|
|
1269
|
-
* 返回选中的 item(含 id);null=取消 / 非 TTY / 空列表。纯导航(不收文本输入)。
|
|
1270
|
-
* 长列表超屏高自动开窗保光标可见;默认聚焦首项。
|
|
1271
|
-
*/
|
|
1272
|
-
export async function promptThemePicker(items) {
|
|
1273
|
-
if (!layout.isActive() || items.length === 0)
|
|
1274
|
-
return null;
|
|
1275
|
-
const emitter = stdin;
|
|
1276
|
-
let selected = 0;
|
|
1277
|
-
let resolved = false;
|
|
1278
|
-
let resolve;
|
|
1279
|
-
let reject;
|
|
1280
|
-
const hint = t('prompt.chooseSwitch');
|
|
1281
|
-
/** 菜单行(带开窗):超屏高时以 selected 为中心取窗,保光标可见。行格式:▸ N title subtitle。 */
|
|
1282
|
-
function menuLines() {
|
|
1283
|
-
const g = layout.getGeo();
|
|
1284
|
-
const cols = g.cols;
|
|
1285
|
-
const maxRows = Math.max(1, g.contentBottom);
|
|
1286
|
-
let start = 0;
|
|
1287
|
-
if (items.length > maxRows) {
|
|
1288
|
-
start = Math.max(0, Math.min(selected - Math.floor(maxRows / 2), items.length - maxRows));
|
|
1289
|
-
}
|
|
1290
|
-
const count = Math.min(maxRows, items.length);
|
|
1291
|
-
return Array.from({ length: count }, (_, i) => {
|
|
1292
|
-
const idx = start + i;
|
|
1293
|
-
const isSel = idx === selected;
|
|
1294
|
-
const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
|
|
1295
|
-
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
1296
|
-
const num = String(idx + 1);
|
|
1297
|
-
const it = items[idx];
|
|
1298
|
-
const title = it.title || '(无)';
|
|
1299
|
-
const sub = it.subtitle ?? '';
|
|
1300
|
-
const leadW = displayWidth(num) + 4; // "▸ " + num + " "
|
|
1301
|
-
let subW = sub ? displayWidth(sub) + 2 : 0; // " " + sub
|
|
1302
|
-
let titleW = cols - leadW - subW;
|
|
1303
|
-
if (titleW < 4 && sub) {
|
|
1304
|
-
subW = 0;
|
|
1305
|
-
titleW = cols - leadW;
|
|
1306
|
-
}
|
|
1307
|
-
const titleT = titleW > 0 ? truncateDisplay(title, titleW) : '';
|
|
1308
|
-
const subPart = subW > 0 ? ` ${sub}` : '';
|
|
1309
|
-
return `${marker} ${color}${num} ${titleT}${subPart}${ui.reset}`;
|
|
1310
|
-
});
|
|
1311
|
-
}
|
|
1312
|
-
function redraw() {
|
|
1313
|
-
layout.paintInput({
|
|
1314
|
-
prompt: '❯ ',
|
|
1315
|
-
lines: [hint],
|
|
1316
|
-
cursorLine: 0,
|
|
1317
|
-
cursorCol: displayWidth(hint),
|
|
1318
|
-
menu: { lines: menuLines() },
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
function cleanup() {
|
|
1322
|
-
layout.setMouseEnabled(true);
|
|
1323
|
-
try {
|
|
1324
|
-
stdin.setRawMode(false);
|
|
1325
|
-
}
|
|
1326
|
-
catch {
|
|
1327
|
-
// 忽略
|
|
1328
|
-
}
|
|
1329
|
-
emitter.removeListener('keypress', onKey);
|
|
1330
|
-
stdin.pause();
|
|
1331
|
-
}
|
|
1332
|
-
function finish(value) {
|
|
1333
|
-
if (resolved)
|
|
1334
|
-
return;
|
|
1335
|
-
resolved = true;
|
|
1336
|
-
cleanup();
|
|
1337
|
-
resolve(value);
|
|
1338
|
-
}
|
|
1339
|
-
function onKey(_str, key) {
|
|
1340
|
-
if (resolved || !key)
|
|
1341
|
-
return;
|
|
1342
|
-
if (mouse.swallow(key.sequence ?? ''))
|
|
1343
|
-
return;
|
|
1344
|
-
if (key.ctrl && key.name === 'c') {
|
|
1345
|
-
cleanup();
|
|
1346
|
-
reject(new Error('SIGINT'));
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
|
-
if (key.ctrl && key.name === 'd') {
|
|
1350
|
-
finish(null);
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
const n = items.length;
|
|
1354
|
-
switch (key.name) {
|
|
1355
|
-
case 'up':
|
|
1356
|
-
selected = (selected - 1 + n) % n;
|
|
1357
|
-
redraw();
|
|
1358
|
-
return;
|
|
1359
|
-
case 'down':
|
|
1360
|
-
selected = (selected + 1) % n;
|
|
1361
|
-
redraw();
|
|
1362
|
-
return;
|
|
1363
|
-
case 'return':
|
|
1364
|
-
case 'enter':
|
|
1365
|
-
finish(items[selected] ?? null);
|
|
1366
|
-
return;
|
|
1367
|
-
case 'escape':
|
|
1368
|
-
finish(null);
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
return new Promise((res, rej) => {
|
|
1373
|
-
resolve = res;
|
|
1374
|
-
reject = rej;
|
|
1375
|
-
layout.setMouseEnabled(false);
|
|
1376
|
-
ensurePasteDetector();
|
|
1377
|
-
readline.emitKeypressEvents(stdin);
|
|
1378
|
-
let rawOk = true;
|
|
1379
|
-
try {
|
|
1380
|
-
stdin.setRawMode(true);
|
|
1381
|
-
}
|
|
1382
|
-
catch {
|
|
1383
|
-
rawOk = false;
|
|
1384
|
-
}
|
|
1385
|
-
if (!rawOk) {
|
|
1386
|
-
res(null);
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
stdin.resume();
|
|
1390
|
-
emitter.on('keypress', onKey);
|
|
1391
|
-
redraw();
|
|
1392
|
-
});
|
|
1393
|
-
}
|
|
1394
|
-
/**
|
|
1395
|
-
* 回滚方式二选一菜单(供 /rollback 选完轮次后):↑/↓ 导航、Enter 确认、Esc/Ctrl+D 保留文件。
|
|
1396
|
-
* 两个选项:1=撤销文件改动(恢复到回滚前);2=只撤销消息,保留文件改动。
|
|
1397
|
-
* 复用轮次/会话菜单的渲染骨架(raw mode + layout.paintInput 的 menu),选中项 cyan+bold + ▸ 高亮。
|
|
1398
|
-
* 返回 true=撤销文件 / false=只撤销消息 / null=取消(调用方按 false=保留文件处理)。
|
|
1399
|
-
* 默认聚焦首项(撤销文件)。无文件改动时调用方不应调此菜单(直接走只撤销消息)。
|
|
1400
|
-
*/
|
|
1401
|
-
export async function promptRevertChoice(fileCount) {
|
|
1402
|
-
if (!layout.isActive())
|
|
1403
|
-
return null;
|
|
1404
|
-
const emitter = stdin;
|
|
1405
|
-
const hint = t('prompt.keepFiles');
|
|
1406
|
-
const detail = fileCount > 0 ? t('prompt.revertDetail', { count: fileCount }) : '';
|
|
1407
|
-
const items = [
|
|
1408
|
-
t('prompt.revertFiles', { detail }),
|
|
1409
|
-
t('prompt.messagesOnly'),
|
|
1410
|
-
];
|
|
1411
|
-
let selected = 0; // 默认聚焦首项(撤销文件)
|
|
1412
|
-
let resolved = false;
|
|
1413
|
-
let resolve;
|
|
1414
|
-
let reject;
|
|
1415
|
-
function menuLines() {
|
|
1416
|
-
const cols = layout.getGeo().cols;
|
|
1417
|
-
return items.map((text, idx) => {
|
|
1418
|
-
const isSel = idx === selected;
|
|
1419
|
-
const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
|
|
1420
|
-
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
1421
|
-
const num = `${color}${idx + 1}${ui.reset}`;
|
|
1422
|
-
const t = truncateDisplay(text, cols - 6);
|
|
1423
|
-
return `${marker} ${num} ${color}${t}${ui.reset}`;
|
|
1424
|
-
});
|
|
1425
|
-
}
|
|
1426
|
-
function redraw() {
|
|
1427
|
-
layout.paintInput({
|
|
1428
|
-
prompt: '❯ ',
|
|
1429
|
-
lines: [hint],
|
|
1430
|
-
cursorLine: 0,
|
|
1431
|
-
cursorCol: displayWidth(hint),
|
|
1432
|
-
menu: { lines: menuLines() },
|
|
1433
|
-
});
|
|
1434
|
-
}
|
|
1435
|
-
function cleanup() {
|
|
1436
|
-
layout.setMouseEnabled(true);
|
|
1437
|
-
try {
|
|
1438
|
-
stdin.setRawMode(false);
|
|
1439
|
-
}
|
|
1440
|
-
catch {
|
|
1441
|
-
// 忽略
|
|
1442
|
-
}
|
|
1443
|
-
emitter.removeListener('keypress', onKey);
|
|
1444
|
-
stdin.pause();
|
|
1445
|
-
}
|
|
1446
|
-
function finish(value) {
|
|
1447
|
-
if (resolved)
|
|
1448
|
-
return;
|
|
1449
|
-
resolved = true;
|
|
1450
|
-
cleanup();
|
|
1451
|
-
resolve(value);
|
|
1452
|
-
}
|
|
1453
|
-
function onKey(_str, key) {
|
|
1454
|
-
if (resolved || !key)
|
|
1455
|
-
return;
|
|
1456
|
-
if (mouse.swallow(key.sequence ?? ''))
|
|
1457
|
-
return;
|
|
1458
|
-
if (key.ctrl && key.name === 'c') {
|
|
1459
|
-
cleanup();
|
|
1460
|
-
reject(new Error('SIGINT'));
|
|
1461
|
-
return;
|
|
1462
|
-
}
|
|
1463
|
-
if (key.ctrl && key.name === 'd') {
|
|
1464
|
-
finish(null);
|
|
1465
|
-
return;
|
|
1466
|
-
}
|
|
1467
|
-
switch (key.name) {
|
|
1468
|
-
case 'up':
|
|
1469
|
-
selected = (selected - 1 + items.length) % items.length;
|
|
1470
|
-
redraw();
|
|
1471
|
-
return;
|
|
1472
|
-
case 'down':
|
|
1473
|
-
selected = (selected + 1) % items.length;
|
|
1474
|
-
redraw();
|
|
1475
|
-
return;
|
|
1476
|
-
case 'return':
|
|
1477
|
-
case 'enter':
|
|
1478
|
-
finish(selected === 0); // true=撤销文件, false=只撤销消息
|
|
1479
|
-
return;
|
|
1480
|
-
case 'escape':
|
|
1481
|
-
finish(null); // 取消=保留文件
|
|
1482
|
-
return;
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
return new Promise((res, rej) => {
|
|
1486
|
-
resolve = res;
|
|
1487
|
-
reject = rej;
|
|
1488
|
-
layout.setMouseEnabled(false);
|
|
1489
|
-
ensurePasteDetector();
|
|
1490
|
-
readline.emitKeypressEvents(stdin);
|
|
1491
|
-
let rawOk = true;
|
|
1492
|
-
try {
|
|
1493
|
-
stdin.setRawMode(true);
|
|
1494
|
-
}
|
|
1495
|
-
catch {
|
|
1496
|
-
rawOk = false;
|
|
1497
|
-
}
|
|
1498
|
-
if (!rawOk) {
|
|
1499
|
-
res(null);
|
|
1500
|
-
return;
|
|
1501
|
-
}
|
|
1502
|
-
stdin.resume();
|
|
1503
|
-
emitter.on('keypress', onKey);
|
|
1504
|
-
redraw();
|
|
1505
|
-
});
|
|
1506
|
-
}
|
|
1
|
+
// 桶文件:保持对外 import 路径不变(from '../ui/prompt.js')
|
|
2
|
+
// 实际实现已拆分到 ui/prompt-internal/ 目录下
|
|
3
|
+
export { promptWithSlashMenu } from './prompt-internal/editor.js';
|
|
4
|
+
export { promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from './prompt-internal/pickers.js';
|