mocode-ai 1.2.9 → 1.3.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/dist/agent/index.js +5 -1
- package/dist/i18n/index.js +8 -0
- package/dist/permissions/index.js +3 -0
- package/dist/repl/index.js +89 -1
- package/dist/ui/intervention.js +4 -1
- package/dist/ui/layout.js +17 -5
- package/dist/ui/prompt.js +372 -291
- package/package.json +1 -1
package/dist/agent/index.js
CHANGED
|
@@ -255,7 +255,11 @@ onContextUpdate) {
|
|
|
255
255
|
// 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
|
|
256
256
|
// 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
|
|
257
257
|
const spinner = new Spinner((msg, frame) => {
|
|
258
|
-
|
|
258
|
+
// frame 在转:msg + … + 帧字符;stop() 时 frame=null,去帧,保留 msg 作为状态文字
|
|
259
|
+
// (spinner.ts line 71 注释:"状态行去帧,保留状态文字")。旧版用 '' 会把 statusText 清空,
|
|
260
|
+
// 导致流式期间(spinner 已停、走时由 turnTimer 兜底)状态行只剩心跳帧字符,
|
|
261
|
+
// 再叠加 composeSpinnerLine scrolled 分支无 fallback,滚动态左侧像"蒸发"了。
|
|
262
|
+
layout.setStatus(frame ? `${msg}…` : msg, frame ?? undefined);
|
|
259
263
|
});
|
|
260
264
|
// lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
|
|
261
265
|
// core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
|
package/dist/i18n/index.js
CHANGED
|
@@ -122,6 +122,10 @@ const zhCN = {
|
|
|
122
122
|
'repl.themeList': '可用主题:',
|
|
123
123
|
'repl.themeCurrent': '(当前:{theme};用 /theme <名称> 切换)',
|
|
124
124
|
'repl.themeChanged': '(已切换主题 {theme})',
|
|
125
|
+
'repl.modeCurrent': '当前模式:{mode};/plan 切到只读探查并产出计划,/auto 切回全工具执行',
|
|
126
|
+
'repl.unknownCommand': '未知命令:{value}',
|
|
127
|
+
'repl.didYouMean': '你是不是想输入 {value}?',
|
|
128
|
+
'repl.unknownCommandHint': '输入 /help 查看全部命令(普通文本直接打字发送,不必加 /)',
|
|
125
129
|
'theme.default': '16 色原版(深底)',
|
|
126
130
|
'theme.light': '浅底终端',
|
|
127
131
|
'theme.solarized': 'Solarized 强调色',
|
|
@@ -381,6 +385,10 @@ const en = {
|
|
|
381
385
|
'repl.themeList': 'Available themes:',
|
|
382
386
|
'repl.themeCurrent': '(current: {theme}; use /theme <name> to switch)',
|
|
383
387
|
'repl.themeChanged': '(switched theme to {theme})',
|
|
388
|
+
'repl.modeCurrent': 'Current mode: {mode}; /plan for read-only planning, /auto for full tool access',
|
|
389
|
+
'repl.unknownCommand': 'Unknown command: {value}',
|
|
390
|
+
'repl.didYouMean': 'Did you mean {value}?',
|
|
391
|
+
'repl.unknownCommandHint': 'Run /help for all commands (plain text is sent as-is, no slash needed)',
|
|
384
392
|
'theme.default': 'Original 16-color theme (dark)',
|
|
385
393
|
'theme.light': 'Light terminal',
|
|
386
394
|
'theme.solarized': 'Solarized accents',
|
|
@@ -146,6 +146,9 @@ export async function checkPermission(tool, args, signal, options = {}) {
|
|
|
146
146
|
detail: summarizeArgs(args) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : ''),
|
|
147
147
|
options: choices,
|
|
148
148
|
allowCustom: false,
|
|
149
|
+
// dangerous 默认落在「拒绝」:面板一弹出就高亮"允许一次"时,用户顺手回车就把命令放出去了。
|
|
150
|
+
// 放行必须是一次明确的选择(↑↓ 移到某一项,或按数字键),而不是默认项的惯性。
|
|
151
|
+
defaultIndex: dangerous ? choices.indexOf(denyOption) : 0,
|
|
149
152
|
});
|
|
150
153
|
if (signal?.aborted || result.action === 'cancelled' || result.value === denyOption)
|
|
151
154
|
return 'deny';
|
package/dist/repl/index.js
CHANGED
|
@@ -144,6 +144,73 @@ function slashHelpLines(nodes = buildSlashCommands(), parentPath = '', depth = 0
|
|
|
144
144
|
}
|
|
145
145
|
return lines;
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* 菜单树里全部可用命令名(含分支路径与叶子的真实命令),供未知命令纠错建议用。
|
|
149
|
+
* /quit、/frontend 确实可用但没登记进菜单树,单独补上——否则用户照提示打了却仍被判未知。
|
|
150
|
+
*/
|
|
151
|
+
function knownCommandNames() {
|
|
152
|
+
const out = [];
|
|
153
|
+
const walk = (nodes, parent) => {
|
|
154
|
+
for (const node of nodes) {
|
|
155
|
+
const path = parent ? `${parent} ${node.name}` : node.name;
|
|
156
|
+
out.push(path);
|
|
157
|
+
const actual = node.value?.trim();
|
|
158
|
+
if (actual)
|
|
159
|
+
out.push(actual.split(/\s+/)[0]);
|
|
160
|
+
if (node.children?.length)
|
|
161
|
+
walk(node.children, path);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
walk(buildSlashCommands(), '');
|
|
165
|
+
out.push('/quit', '/frontend');
|
|
166
|
+
return [...new Set(out)];
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 是否"长得像一条斜杠命令":单个前导 / + 词字符(字母开头,可含数字/下划线/连字符)。
|
|
170
|
+
* 用来把 /hepl 这类拼写错误与 /tmp/foo、/src/index.ts:12:5 这类路径区分开——
|
|
171
|
+
* 后者在编码 agent 里很常见,同样以 / 开头,绝不能被当成命令拦下。
|
|
172
|
+
*/
|
|
173
|
+
function isCommandShape(cmd) {
|
|
174
|
+
return /^\/[A-Za-z][\w-]*$/.test(cmd);
|
|
175
|
+
}
|
|
176
|
+
/** Levenshtein 距离(标准 DP)。短命令字符串,直接两行数组滚动即可。 */
|
|
177
|
+
function editDistance(a, b) {
|
|
178
|
+
if (a === b)
|
|
179
|
+
return 0;
|
|
180
|
+
if (!a.length)
|
|
181
|
+
return b.length;
|
|
182
|
+
if (!b.length)
|
|
183
|
+
return a.length;
|
|
184
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
185
|
+
for (let i = 1; i <= a.length; i++) {
|
|
186
|
+
const cur = [i];
|
|
187
|
+
for (let j = 1; j <= b.length; j++) {
|
|
188
|
+
cur[j] = Math.min(prev[j] + 1, // 删
|
|
189
|
+
cur[j - 1] + 1, // 插
|
|
190
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
191
|
+
}
|
|
192
|
+
prev = cur;
|
|
193
|
+
}
|
|
194
|
+
return prev[b.length];
|
|
195
|
+
}
|
|
196
|
+
/** 未知命令的纠错建议:返回编辑距离最近且在阈值内的命令名,没有则 null(不要瞎猜)。 */
|
|
197
|
+
export function suggestCommand(input) {
|
|
198
|
+
// 阈值随长度放宽,但对短命令保持严格:/hepl(5) vs /help 距离 2 → 命中;
|
|
199
|
+
// /x(2) 不该被建议成任何东西。
|
|
200
|
+
const threshold = Math.max(1, Math.ceil(input.length / 3));
|
|
201
|
+
let best = null;
|
|
202
|
+
let bestDist = Infinity;
|
|
203
|
+
for (const name of knownCommandNames()) {
|
|
204
|
+
if (name === input)
|
|
205
|
+
continue; // 别建议"你自己"(分支节点如 /mode 也在候选里)
|
|
206
|
+
const d = editDistance(input, name);
|
|
207
|
+
if (d < bestDist) {
|
|
208
|
+
bestDist = d;
|
|
209
|
+
best = name;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return bestDist <= threshold ? best : null;
|
|
213
|
+
}
|
|
147
214
|
/** 主题名 → 本地化描述。 */
|
|
148
215
|
function themeDescription(name) {
|
|
149
216
|
const key = `theme.${name}`;
|
|
@@ -1150,7 +1217,6 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1150
1217
|
input = await promptWithSlashMenu({
|
|
1151
1218
|
prompt: PROMPT,
|
|
1152
1219
|
commands: buildSlashCommands(),
|
|
1153
|
-
queryHistory,
|
|
1154
1220
|
onCycleMode: cycleMode,
|
|
1155
1221
|
// /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
|
|
1156
1222
|
...(pendingPrefill
|
|
@@ -1176,6 +1242,8 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1176
1242
|
break;
|
|
1177
1243
|
// RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
|
|
1178
1244
|
const cmd = line.split(/\s+/)[0];
|
|
1245
|
+
// 命令被刻意改写成别的内容后转发给 agent(如 /init)。这类不算"未知命令"。
|
|
1246
|
+
let forwardToAgent = false;
|
|
1179
1247
|
// 语言命令把视觉分隔放在确认文案之后;避免回显后先空一行、下一条命令却紧贴确认。
|
|
1180
1248
|
echoInput(input, cmd !== '/language');
|
|
1181
1249
|
const state = runningStateFor(cmd);
|
|
@@ -1217,6 +1285,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1217
1285
|
if (line === '/init') {
|
|
1218
1286
|
// /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 AGENTS.md),fall through 走 runAgent
|
|
1219
1287
|
joined = buildInitPrompt();
|
|
1288
|
+
forwardToAgent = true; // 不是"没匹配上",别被下方未知命令兜底拦掉
|
|
1220
1289
|
}
|
|
1221
1290
|
if (line === '/upgrade' || line.startsWith('/upgrade ')) {
|
|
1222
1291
|
const arg = line === '/upgrade' ? '' : line.slice('/upgrade '.length).trim().toLowerCase();
|
|
@@ -1317,6 +1386,12 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1317
1386
|
}
|
|
1318
1387
|
continue;
|
|
1319
1388
|
}
|
|
1389
|
+
if (line === '/mode') {
|
|
1390
|
+
// /mode:菜单里的分支节点(本身不切换,只挂 /plan 与 /auto)。/help 会把它列出来,
|
|
1391
|
+
// 所以直接敲它必须给点有用的东西——打印当前模式与两个子命令,而不是报未知命令。
|
|
1392
|
+
layout.contentWrite(`${ui.dim}${t('repl.modeCurrent', { mode: getAgentMode() })}${ui.reset}\n`);
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1320
1395
|
if (line === '/pet quit') {
|
|
1321
1396
|
// /pet quit:完全关闭桌宠进程(区别于 /pet 的仅断开本连接)。方案C的 CLI 侧退出入口,
|
|
1322
1397
|
// 另一入口是桌宠托盘菜单"退出桌宠"(见 packages/pet-app/src/main.ts)。
|
|
@@ -2293,6 +2368,19 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
2293
2368
|
}
|
|
2294
2369
|
continue;
|
|
2295
2370
|
}
|
|
2371
|
+
// 未知斜杠命令兜底:命令分派是一串独立的 if(…){ …continue },没有 else,
|
|
2372
|
+
// 拼错的 /hepl 会一路走到这里被当成普通 prompt 送给模型——白烧一轮还拿不到任何提示。
|
|
2373
|
+
// 只拦「长得像命令」的输入:单个前导 / + 词字符。像 /tmp/foo/bar、/src/index.ts:12:5 这类
|
|
2374
|
+
// 路径(在编码 agent 里很常见,且同样以 / 开头)必须放行,不能当成拼错的命令。
|
|
2375
|
+
if (isCommandShape(cmd) && !forwardToAgent) {
|
|
2376
|
+
const suggestion = suggestCommand(cmd);
|
|
2377
|
+
layout.contentWrite(`${ui.yellow}${t('repl.unknownCommand', { value: cmd })}${ui.reset}\n`);
|
|
2378
|
+
if (suggestion) {
|
|
2379
|
+
layout.contentWrite(`${ui.dim}${t('repl.didYouMean', { value: suggestion })}${ui.reset}\n`);
|
|
2380
|
+
}
|
|
2381
|
+
layout.contentWrite(`${ui.dim}${t('repl.unknownCommandHint')}${ui.reset}\n`);
|
|
2382
|
+
continue; // 与其他命令一致:回 INPUT 态由循环顶部的 normalizeInputBoundary + enterInputMode 负责
|
|
2383
|
+
}
|
|
2296
2384
|
const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
|
|
2297
2385
|
const shouldCommit = await awaitPendingRecall(input, pendingAttachments.length, placeholder);
|
|
2298
2386
|
if (!shouldCommit) {
|
package/dist/ui/intervention.js
CHANGED
|
@@ -33,7 +33,10 @@ export async function promptIntervention(req) {
|
|
|
33
33
|
const startMode = req.type === 'choice' && options.length > 0 ? 'choice' : 'input';
|
|
34
34
|
let mode = startMode;
|
|
35
35
|
// choice:选中下标(0..options.length-1 为各选项,options.length 为"自定义"项)。
|
|
36
|
-
|
|
36
|
+
// 初始选中项:默认 0(首项);调用方可传 defaultIndex 改。危险操作用它把默认落在「拒绝」上,
|
|
37
|
+
// 否则面板一弹出就高亮「允许一次」,用户顺手回车就把 rm -rf 放出去了。只认真实选项,不含"自定义"项。
|
|
38
|
+
const defaultIndex = Math.max(0, Math.min(req.defaultIndex ?? 0, Math.max(0, options.length - 1)));
|
|
39
|
+
let selected = defaultIndex;
|
|
37
40
|
// input:可编辑文本 + 光标(UTF-16 码元索引;显示位置由 displayWidth 算,见 paintInput)。
|
|
38
41
|
let text = req.seed ?? '';
|
|
39
42
|
let cursor = text.length;
|
package/dist/ui/layout.js
CHANGED
|
@@ -873,6 +873,13 @@ export function scrollBy(delta) {
|
|
|
873
873
|
repaintViewport();
|
|
874
874
|
repaint();
|
|
875
875
|
}
|
|
876
|
+
/**
|
|
877
|
+
* 滚动一格「滚轮量」(WHEEL_LINES 行):dir=+1 看更旧(等同 ↑ / PgUp),dir=-1 看更新(等同 ↓ / PgDn)。
|
|
878
|
+
* 鼠标滚轮与键盘裸 ↑/↓ 共用同一入口,保证两者行为与步长恒一致。
|
|
879
|
+
*/
|
|
880
|
+
export function scrollWheel(dir) {
|
|
881
|
+
scrollBy(dir > 0 ? WHEEL_LINES : -WHEEL_LINES);
|
|
882
|
+
}
|
|
876
883
|
/** 清活跃选区(不复制),若原有选区则重画去掉反白。供 ESC / 点击别处 / 退出滚动态等场景调。 */
|
|
877
884
|
export function clearSelection() {
|
|
878
885
|
if (!selection && !inputSelection)
|
|
@@ -1153,7 +1160,7 @@ function handleMouseEvent(e) {
|
|
|
1153
1160
|
if (e.type === 'wheel') {
|
|
1154
1161
|
// 滚轮始终可用:面板/picker 期间也允许上下查看 agent 输出,
|
|
1155
1162
|
// 与 onRunningKey 的 PgUp/PgDn 行为一致;mouseEnabled 仅管选区/拖拽。
|
|
1156
|
-
|
|
1163
|
+
scrollWheel(e.dir);
|
|
1157
1164
|
return;
|
|
1158
1165
|
}
|
|
1159
1166
|
// 输入行左键点击:即使 mouseEnabled=false(如 intervention 面板期间)也允许定位光标,
|
|
@@ -1357,13 +1364,18 @@ function composeSpinnerLine(status, cols) {
|
|
|
1357
1364
|
let lead;
|
|
1358
1365
|
let leadW;
|
|
1359
1366
|
if (scrolled) {
|
|
1360
|
-
// 滚动回看:左段 = 符号(● 或 心跳帧) + 状态名(
|
|
1361
|
-
//
|
|
1367
|
+
// 滚动回看:左段 = 符号(● 或 心跳帧) + 状态名 + (运行态)走时;右段 = 历史指示。
|
|
1368
|
+
// 跟非回看态严格对齐——RUNNING 态显示走时,INPUT 态不显示,避免滚动时信息降级。
|
|
1362
1369
|
const symbol = spinning
|
|
1363
1370
|
? `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset}`
|
|
1364
1371
|
: `${ui.accent}●${ui.reset}`;
|
|
1365
|
-
|
|
1366
|
-
|
|
1372
|
+
// spinning(运行态流式中 spinner 已停,靠 turnTimer 心跳):同非滚动 spinning 分支加
|
|
1373
|
+
// || '生成中' fallback——流式首 token 到即 spinner.stop(),若 stop() 回调未留状态字,
|
|
1374
|
+
// 此处兜底不再让 lead 只剩心跳帧字符(用户误读为"左侧蒸发了")。
|
|
1375
|
+
const label = spinning ? (status.status || '生成中') : status.status;
|
|
1376
|
+
const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
|
|
1377
|
+
lead = `${symbol} ${ui.dim}${label}${ui.reset}${ePart}`;
|
|
1378
|
+
leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
|
|
1367
1379
|
}
|
|
1368
1380
|
else if (hasSpinner) {
|
|
1369
1381
|
// spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
|
package/dist/ui/prompt.js
CHANGED
|
@@ -73,76 +73,24 @@ export async function promptWithSlashMenu(opts) {
|
|
|
73
73
|
}
|
|
74
74
|
const emitter = stdin;
|
|
75
75
|
const promptW = displayWidth(opts.prompt);
|
|
76
|
-
// initialLines(运行中 typeahead 预填)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
:
|
|
80
|
-
|
|
81
|
-
let
|
|
76
|
+
// initialLines(运行中 typeahead 预填):用调用方给的行初始化;光标置文末。
|
|
77
|
+
// 整篇输入是一个 Seg[]:可编辑段与粘贴块交替。光标永远落在某个可编辑段(segs[curSeg].frozen===false)上。
|
|
78
|
+
let segs = opts.initialLines && opts.initialLines.length > 0
|
|
79
|
+
? [{ frozen: false, text: opts.initialLines.join('\n') }]
|
|
80
|
+
: [{ frozen: false, text: '' }];
|
|
81
|
+
let curSeg = 0; // 光标所在段(始终可编辑)
|
|
82
|
+
let curOff = segs[0].text.length; // 段内字符偏移 = 文末
|
|
82
83
|
let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
|
|
83
|
-
let chip = null; // 原子粘贴块:整段封进 [预览…],不可编辑;提交时拼回全文
|
|
84
|
-
let chipPre = ''; // chip 之前已存在的文本(粘贴发生时光标前的原文,原样多行保留,不截断);随 chip 一起提交/删除
|
|
85
84
|
let menuOpen = false;
|
|
86
85
|
let selected = 0;
|
|
87
86
|
let filtered = [];
|
|
88
87
|
const MENU_MAX_VISIBLE = 7;
|
|
89
88
|
let menuTop = 0; // 窗口首项在 filtered 中的索引,菜单最多显示 MENU_MAX_VISIBLE 条
|
|
89
|
+
/** Ctrl+C 清空前的缓冲快照(Ctrl+Z 还原一次);单次生效,还原后即清。 */
|
|
90
|
+
let undoSnapshot = null;
|
|
90
91
|
let resolved = false;
|
|
91
92
|
let resolve;
|
|
92
93
|
let reject;
|
|
93
|
-
const queryHistory = opts.queryHistory ?? [];
|
|
94
|
-
let historyIndex = queryHistory.length; // length = 草稿哨兵;0..length-1 = 历史项
|
|
95
|
-
let historyDraft = null;
|
|
96
|
-
/** 用历史 query 替换编辑缓冲;历史内容恢复为普通可编辑文本。 */
|
|
97
|
-
function loadHistoryEntry(value) {
|
|
98
|
-
lines = value.split('\n');
|
|
99
|
-
cl = lines.length - 1;
|
|
100
|
-
cc = lines[cl].length;
|
|
101
|
-
chip = null;
|
|
102
|
-
chipPre = '';
|
|
103
|
-
selected = 0;
|
|
104
|
-
menuTop = 0;
|
|
105
|
-
justSawCR = false;
|
|
106
|
-
computeFiltered();
|
|
107
|
-
redraw();
|
|
108
|
-
}
|
|
109
|
-
/** 在首次离开最新位置时保存完整草稿,然后向更旧的 query 移动。 */
|
|
110
|
-
function recallPrevious() {
|
|
111
|
-
if (queryHistory.length === 0 || historyIndex <= 0)
|
|
112
|
-
return;
|
|
113
|
-
if (historyIndex === queryHistory.length) {
|
|
114
|
-
historyDraft = {
|
|
115
|
-
lines: [...lines],
|
|
116
|
-
cl,
|
|
117
|
-
cc,
|
|
118
|
-
chip,
|
|
119
|
-
chipPre,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
historyIndex--;
|
|
123
|
-
loadHistoryEntry(queryHistory[historyIndex]);
|
|
124
|
-
}
|
|
125
|
-
/** 向更新的 query 移动;越过最新历史时恢复进入历史前的草稿。 */
|
|
126
|
-
function recallNext() {
|
|
127
|
-
if (historyIndex >= queryHistory.length)
|
|
128
|
-
return;
|
|
129
|
-
historyIndex++;
|
|
130
|
-
if (historyIndex < queryHistory.length) {
|
|
131
|
-
loadHistoryEntry(queryHistory[historyIndex]);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const draft = historyDraft;
|
|
135
|
-
lines = draft ? [...draft.lines] : [''];
|
|
136
|
-
cl = draft?.cl ?? 0;
|
|
137
|
-
cc = draft?.cc ?? 0;
|
|
138
|
-
chip = draft?.chip ?? null;
|
|
139
|
-
chipPre = draft?.chipPre ?? '';
|
|
140
|
-
selected = 0;
|
|
141
|
-
menuTop = 0;
|
|
142
|
-
justSawCR = false;
|
|
143
|
-
computeFiltered();
|
|
144
|
-
redraw();
|
|
145
|
-
}
|
|
146
94
|
/** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
|
|
147
95
|
function menuLines() {
|
|
148
96
|
if (!menuOpen || filtered.length === 0)
|
|
@@ -180,90 +128,163 @@ export async function promptWithSlashMenu(opts) {
|
|
|
180
128
|
return `${marker} ${color}${name}${ui.reset} ${color}${descStr}${ui.reset}${ui.dim}${scrollHint}${ui.reset}`;
|
|
181
129
|
});
|
|
182
130
|
}
|
|
183
|
-
/**
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* ——后者在中文里 20 列只够 10 个汉字就截没,几乎看不到内容;元信息密度更高、长度可预测。 */
|
|
189
|
-
function chipPrefix() {
|
|
190
|
-
if (!chip)
|
|
191
|
-
return '';
|
|
192
|
-
const lines = chip.split('\n').length;
|
|
193
|
-
const chars = chip.length;
|
|
194
|
-
const lineText = t('prompt.lines', { count: lines });
|
|
131
|
+
/** 单个 chip 的展示 token:[📋 行数 · 字符数] (含尾部空格,使相邻 chip 之间留空隙)。 */
|
|
132
|
+
function chipToken(text) {
|
|
133
|
+
const lineCount = text.split('\n').length;
|
|
134
|
+
const charCount = text.length;
|
|
135
|
+
const lineText = t('prompt.lines', { count: lineCount });
|
|
195
136
|
const charText = t('prompt.chars', {
|
|
196
|
-
count:
|
|
137
|
+
count: charCount < 1000 ? charCount : `${(charCount / 1000).toFixed(1)}K`,
|
|
197
138
|
});
|
|
198
139
|
return `[📋 ${lineText} · ${charText}] `;
|
|
199
140
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
141
|
+
/** 构建整段显示:可编辑段按原文(含换行)展开,粘贴块(chip)折叠成一行 token 内联在光标原位。
|
|
142
|
+
* 返回展示行数组,以及每个段在"展示串"(= 展示行以 \n 拼接)中的字符起点,供光标正/反向映射。
|
|
143
|
+
* 关键不变量:展示串与 segs 的"段贡献"长度一致(可编辑段贡献其原文长度,含其中 \n;
|
|
144
|
+
* chip 贡献其 token 长度,无 \n),故 segStart 同时是 dispLines.join('\n') 内的字符偏移。 */
|
|
145
|
+
function buildDisplay() {
|
|
146
|
+
const out = [];
|
|
147
|
+
const segStart = [];
|
|
148
|
+
let gLen = 0;
|
|
149
|
+
for (const s of segs) {
|
|
150
|
+
segStart.push(gLen);
|
|
151
|
+
if (!s.frozen) {
|
|
152
|
+
const ls = s.text.split('\n');
|
|
153
|
+
if (out.length === 0)
|
|
154
|
+
out.push(...ls);
|
|
155
|
+
else {
|
|
156
|
+
out[out.length - 1] += ls[0];
|
|
157
|
+
for (let k = 1; k < ls.length; k++)
|
|
158
|
+
out.push(ls[k]);
|
|
159
|
+
}
|
|
160
|
+
gLen += s.text.length;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const tok = chipToken(s.text);
|
|
164
|
+
if (out.length === 0)
|
|
165
|
+
out.push(tok);
|
|
166
|
+
else
|
|
167
|
+
out[out.length - 1] += tok;
|
|
168
|
+
gLen += tok.length;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (out.length === 0)
|
|
172
|
+
out.push('');
|
|
173
|
+
return { lines: out, segStart };
|
|
174
|
+
}
|
|
175
|
+
/** 当前光标段内显示列。 */
|
|
176
|
+
function cursorCol() {
|
|
177
|
+
return displayWidth(segs[curSeg].text.slice(0, curOff));
|
|
203
178
|
}
|
|
204
|
-
/**
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
179
|
+
/** 光标 → 展示 (行, 列)。展示行内的列以显示宽度计。 */
|
|
180
|
+
function cursorDisp() {
|
|
181
|
+
const { lines: dispLines, segStart } = buildDisplay();
|
|
182
|
+
const off = segStart[curSeg] + curOff;
|
|
183
|
+
let remaining = off;
|
|
184
|
+
let dl = 0;
|
|
185
|
+
let dispColChars = 0;
|
|
186
|
+
for (; dl < dispLines.length; dl++) {
|
|
187
|
+
const L = dispLines[dl].length;
|
|
188
|
+
if (remaining <= L) {
|
|
189
|
+
dispColChars = remaining;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
remaining -= L + 1;
|
|
193
|
+
}
|
|
194
|
+
if (dl >= dispLines.length) {
|
|
195
|
+
dl = Math.max(0, dispLines.length - 1);
|
|
196
|
+
dispColChars = dispLines[dl].length;
|
|
197
|
+
}
|
|
198
|
+
return { line: dl, col: displayWidth(dispLines[dl].slice(0, dispColChars)) };
|
|
213
199
|
}
|
|
214
|
-
/**
|
|
215
|
-
function
|
|
216
|
-
|
|
200
|
+
/** 展示 (行, 显示列) → 段内字符偏移。供 ↑/↓ 移动后重新定位,以及鼠标点击的反向映射。 */
|
|
201
|
+
function dispColToCharInLine(dispLine, dispCol) {
|
|
202
|
+
const { lines } = buildDisplay();
|
|
203
|
+
const ln = lines[dispLine] ?? '';
|
|
204
|
+
return visColToCharCol(ln, dispCol);
|
|
217
205
|
}
|
|
218
|
-
/**
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
206
|
+
/** 展示 (行, 字符内偏移) → 段+偏移。光标永远落在可编辑段;点中 chip 则吸附到最近的可编辑边界。 */
|
|
207
|
+
function mapDispToCursor(dispLine, charInLine) {
|
|
208
|
+
const { lines: dispLines, segStart } = buildDisplay();
|
|
209
|
+
let off = 0;
|
|
210
|
+
for (let k = 0; k < dispLine && k < dispLines.length; k++)
|
|
211
|
+
off += dispLines[k].length + 1;
|
|
212
|
+
off += Math.max(0, Math.min(charInLine, (dispLines[dispLine] ?? '').length));
|
|
213
|
+
for (let i = 0; i < segs.length; i++) {
|
|
214
|
+
const start = segStart[i];
|
|
215
|
+
const len = segs[i].frozen ? chipToken(segs[i].text).length : segs[i].text.length;
|
|
216
|
+
// 可编辑段用闭区间;只读块用半开区间——其结束边界归紧随其后的可编辑段,
|
|
217
|
+
// 否则光标恰好停在 chip 后的可编辑段开头时,会被误判落在 chip 上(边界争用)。
|
|
218
|
+
const inside = segs[i].frozen ? off >= start && off < start + len : off >= start && off <= start + len;
|
|
219
|
+
if (inside) {
|
|
220
|
+
if (!segs[i].frozen)
|
|
221
|
+
return { seg: i, off: off - start };
|
|
222
|
+
// 落在只读块内:按就近原则吸附——靠近左沿→其前可编辑段末尾,靠近右沿→其后可编辑段开头。
|
|
223
|
+
const distStart = off - start;
|
|
224
|
+
const distEnd = start + len - off;
|
|
225
|
+
return distStart <= distEnd ? editableEndBefore(i) : editableStartAfter(i);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return lastEditableEnd();
|
|
224
229
|
}
|
|
225
|
-
|
|
230
|
+
function editableStartAfter(i) {
|
|
231
|
+
let j = i + 1;
|
|
232
|
+
while (j < segs.length && segs[j].frozen)
|
|
233
|
+
j++;
|
|
234
|
+
if (j < segs.length)
|
|
235
|
+
return { seg: j, off: 0 };
|
|
236
|
+
return lastEditableEnd();
|
|
237
|
+
}
|
|
238
|
+
function editableEndBefore(i) {
|
|
239
|
+
let j = i - 1;
|
|
240
|
+
while (j >= 0 && segs[j].frozen)
|
|
241
|
+
j--;
|
|
242
|
+
if (j >= 0)
|
|
243
|
+
return { seg: j, off: segs[j].text.length };
|
|
244
|
+
return firstEditableStart();
|
|
245
|
+
}
|
|
246
|
+
function firstEditableStart() {
|
|
247
|
+
for (let j = 0; j < segs.length; j++)
|
|
248
|
+
if (!segs[j].frozen)
|
|
249
|
+
return { seg: j, off: 0 };
|
|
250
|
+
return { seg: 0, off: 0 };
|
|
251
|
+
}
|
|
252
|
+
function lastEditableEnd() {
|
|
253
|
+
for (let j = segs.length - 1; j >= 0; j--)
|
|
254
|
+
if (!segs[j].frozen)
|
|
255
|
+
return { seg: j, off: segs[j].text.length };
|
|
256
|
+
return { seg: 0, off: 0 };
|
|
257
|
+
}
|
|
258
|
+
/** 在光标处插入文本(含换行则落进当前可编辑段,原样保留)。供短粘贴与可打印字符共用。 */
|
|
226
259
|
function insertText(text) {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const newLines = [before + parts[0]];
|
|
231
|
-
for (let i = 1; i < parts.length; i++)
|
|
232
|
-
newLines.push(parts[i]);
|
|
233
|
-
newLines[newLines.length - 1] += after;
|
|
234
|
-
lines.splice(cl, 1, ...newLines);
|
|
235
|
-
cl = cl + parts.length - 1;
|
|
236
|
-
// 光标置于插入文本末尾 = 原 before + 末段长度(指向插入文本之后、after 之前)。
|
|
237
|
-
// 旧值 parts[...].length 漏算 before → 光标落到插入文本内部(行中间)→ 后续打字插到中间(bug)。
|
|
238
|
-
cc = before.length + parts[parts.length - 1].length;
|
|
260
|
+
const s = segs[curSeg].text;
|
|
261
|
+
segs[curSeg].text = s.slice(0, curOff) + text + s.slice(curOff);
|
|
262
|
+
curOff += text.length;
|
|
239
263
|
}
|
|
240
|
-
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
264
|
+
/** 落一段粘贴文本:长粘贴 → 在此处插入一个只读 chip(把当前可编辑段从光标切开成「前段 + chip + 后段」),
|
|
265
|
+
* 短粘贴 → 作为可编辑文本插入。连续长粘贴会产生相邻 chip,各自独立;两次粘贴之间敲的字是可编辑段,
|
|
266
|
+
* 可直接编辑、也可退格删掉任一 chip。光标落在 chip 之后的可编辑段(即使为空也保留,保证光标有落点)。 */
|
|
243
267
|
function applyPastedText(buf) {
|
|
244
268
|
if (!buf)
|
|
245
269
|
return;
|
|
246
270
|
const isLong = buf.split('\n').length > 8 || buf.length > 200;
|
|
247
|
-
if (isLong) {
|
|
248
|
-
const before = lines[cl].slice(0, cc);
|
|
249
|
-
const after = lines[cl].slice(cc);
|
|
250
|
-
if (chip == null) {
|
|
251
|
-
// 首次起 chip:光标前的行(含之前的整行)转入 chipPre,光标后的部分留作 suffix 首行。
|
|
252
|
-
chipPre = [...lines.slice(0, cl), before].join('\n');
|
|
253
|
-
chip = buf;
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
// 已有 chip:光标前若已打了字(chip 之后、本次粘贴之前),并入 chip 尾部保留(不丢字),
|
|
257
|
-
// 光标后的部分仍留作 suffix 首行。
|
|
258
|
-
chip = before ? chip + '\n' + before + '\n' + buf : chip + '\n' + buf;
|
|
259
|
-
}
|
|
260
|
-
lines = [after, ...lines.slice(cl + 1)];
|
|
261
|
-
cl = 0;
|
|
262
|
-
cc = 0;
|
|
263
|
-
}
|
|
264
|
-
else {
|
|
271
|
+
if (!isLong) {
|
|
265
272
|
insertText(buf);
|
|
273
|
+
computeFiltered();
|
|
274
|
+
redraw();
|
|
275
|
+
return;
|
|
266
276
|
}
|
|
277
|
+
const s = segs[curSeg].text;
|
|
278
|
+
const before = s.slice(0, curOff);
|
|
279
|
+
const after = s.slice(curOff);
|
|
280
|
+
const newSegs = [];
|
|
281
|
+
if (before)
|
|
282
|
+
newSegs.push({ frozen: false, text: before });
|
|
283
|
+
newSegs.push({ frozen: true, text: buf });
|
|
284
|
+
newSegs.push({ frozen: false, text: after }); // 始终保留可编辑后缀(可为空),保证光标有落点
|
|
285
|
+
segs.splice(curSeg, 1, ...newSegs);
|
|
286
|
+
curSeg = curSeg + (before ? 2 : 1); // 落到 chip 之后的可编辑段
|
|
287
|
+
curOff = 0;
|
|
267
288
|
computeFiltered();
|
|
268
289
|
redraw();
|
|
269
290
|
}
|
|
@@ -297,11 +318,12 @@ export async function promptWithSlashMenu(opts) {
|
|
|
297
318
|
}
|
|
298
319
|
}
|
|
299
320
|
function computeFiltered() {
|
|
300
|
-
|
|
301
|
-
|
|
321
|
+
const firstLine = buildDisplay().lines[0] ?? '';
|
|
322
|
+
if (firstLine.startsWith('/')) {
|
|
323
|
+
const { nodes, prefix } = menuContext(firstLine);
|
|
302
324
|
filtered = nodes
|
|
303
325
|
.map((node) => ({ node, input: `${prefix}${node.name}` }))
|
|
304
|
-
.filter((item) => item.input.startsWith(
|
|
326
|
+
.filter((item) => item.input.startsWith(firstLine));
|
|
305
327
|
menuOpen = filtered.length > 0;
|
|
306
328
|
if (selected >= filtered.length) {
|
|
307
329
|
selected = 0;
|
|
@@ -315,37 +337,34 @@ export async function promptWithSlashMenu(opts) {
|
|
|
315
337
|
}
|
|
316
338
|
}
|
|
317
339
|
function redraw() {
|
|
340
|
+
const { lines } = buildDisplay();
|
|
341
|
+
const cur = cursorDisp();
|
|
318
342
|
layout.paintInput({
|
|
319
343
|
prompt: opts.prompt,
|
|
320
|
-
lines
|
|
321
|
-
cursorLine:
|
|
322
|
-
cursorCol:
|
|
344
|
+
lines,
|
|
345
|
+
cursorLine: cur.line,
|
|
346
|
+
cursorCol: cur.col,
|
|
323
347
|
menu: menuLines().length ? { lines: menuLines() } : null,
|
|
324
348
|
});
|
|
325
349
|
}
|
|
326
350
|
/** 鼠标点击输入框:layout 已把"屏 tap 位置"以 (flatIdx, inSegVis) 形式传进来
|
|
327
|
-
* (flatIdx = flat 中的绝对段号;inSegVis = 段内显示列)。
|
|
328
|
-
*
|
|
329
|
-
* chip 模式自动扣 chip prefix:
|
|
330
|
-
* - dispLines() 把 chipPre 末行 + chipPrefix + suffix[0] 拼成一行 "mergedRow"
|
|
331
|
-
* - flat 中对应的"合并行段"是 mergedRow 的折行段,段内 inChar 累加从 mergedRow 起
|
|
332
|
-
* - 要得到 lines[cl=0] 内的字符索引,扣掉 mergedRow 起始到 lines[0] 起始的字符数
|
|
333
|
-
* = chipPre 末行字符数 + chipPrefix 字符数。 */
|
|
351
|
+
* (flatIdx = flat 中的绝对段号;inSegVis = 段内显示列)。本函数把点击位还原成 (段, 偏移) → redraw。
|
|
352
|
+
* 点中只读粘贴块时吸附到最近的可编辑边界(见 mapDispToCursor)。 */
|
|
334
353
|
function applyExternalCursor(flatIdx, inSegVis) {
|
|
335
354
|
const g = layout.getGeo();
|
|
336
355
|
const promptW = displayWidth(opts.prompt);
|
|
337
356
|
const W = Math.max(1, g.cols - promptW);
|
|
338
|
-
const dispLs =
|
|
357
|
+
const { lines: dispLs } = buildDisplay();
|
|
339
358
|
const lineVis = dispLs.map((l) => wrapByDisplayWidth(l, W));
|
|
340
359
|
const flat = [];
|
|
341
360
|
for (const lv of lineVis)
|
|
342
361
|
for (const r of lv)
|
|
343
362
|
flat.push(r);
|
|
344
|
-
let
|
|
345
|
-
let
|
|
363
|
+
let dispLine;
|
|
364
|
+
let charInLine;
|
|
346
365
|
if (flat.length === 0) {
|
|
347
|
-
|
|
348
|
-
|
|
366
|
+
dispLine = 0;
|
|
367
|
+
charInLine = 0;
|
|
349
368
|
}
|
|
350
369
|
else {
|
|
351
370
|
const safeIdx = Math.max(0, Math.min(flatIdx, flat.length - 1));
|
|
@@ -354,60 +373,26 @@ export async function promptWithSlashMenu(opts) {
|
|
|
354
373
|
const safeInSeg = Math.max(0, Math.min(inSegVis, segW));
|
|
355
374
|
const inChar = visColToCharCol(seg, safeInSeg);
|
|
356
375
|
// safeIdx → (dispLine, segInLine)
|
|
357
|
-
let
|
|
376
|
+
let dl = 0;
|
|
358
377
|
let segInLine = safeIdx;
|
|
359
|
-
while (
|
|
360
|
-
segInLine -= lineVis[
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
if (dispLine >= lineVis.length) {
|
|
364
|
-
dispLine = lineVis.length - 1;
|
|
365
|
-
segInLine = lineVis[dispLine].length - 1;
|
|
366
|
-
}
|
|
367
|
-
// dispLine → cl(chip-aware)
|
|
368
|
-
if (chip) {
|
|
369
|
-
const preLen = chipPreLines().length;
|
|
370
|
-
if (dispLine < preLen - 1) {
|
|
371
|
-
// 落在 chipPre 内(非末行)→ 走出 chip 到 suffix 起点
|
|
372
|
-
newCl = 0;
|
|
373
|
-
}
|
|
374
|
-
else if (dispLine === preLen - 1) {
|
|
375
|
-
// 合并行 = suffix 第 0 行(cl=0)
|
|
376
|
-
newCl = 0;
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
newCl = dispLine - (preLen - 1);
|
|
380
|
-
}
|
|
378
|
+
while (dl < lineVis.length && segInLine >= lineVis[dl].length) {
|
|
379
|
+
segInLine -= lineVis[dl].length;
|
|
380
|
+
dl++;
|
|
381
381
|
}
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
if (dl >= lineVis.length) {
|
|
383
|
+
dl = lineVis.length - 1;
|
|
384
|
+
segInLine = lineVis[dl].length - 1;
|
|
384
385
|
}
|
|
385
|
-
newCl = Math.max(0, Math.min(newCl, lines.length - 1));
|
|
386
|
-
const lineText = lines[newCl] ?? '';
|
|
387
|
-
// cc 必须在 lines[newCl] 内。flat 中 seg 是 dispLs[dispLine] 的某折行段,
|
|
388
|
-
// seg 内 inChar 是段内字符偏移,但 lines[newCl] 的字符是按 (dispLs 前段累加) + (本段 inChar) 算的。
|
|
389
|
-
// ——非 chip 模式下:dispLine = newCl, segStartChars = sum(lineVis[dispLine][0..segInLine-1].length)
|
|
390
|
-
// ccInLine = segStartChars + inChar
|
|
391
|
-
// ——chip 模式下 newCl=0(合并行):dispLine 是合并行 dispLine,segStartChars 同上,
|
|
392
|
-
// 但 lines[0] 在合并行中"起始处"在 (mergedRow.length - lines[0].length) 字符处,
|
|
393
|
-
// 故 ccInLine = segStartChars + inChar - chipOverheadChars。
|
|
394
386
|
let segStartChars = 0;
|
|
395
|
-
const
|
|
387
|
+
const segsVis = lineVis[dl];
|
|
396
388
|
for (let s = 0; s < segInLine; s++)
|
|
397
|
-
segStartChars +=
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const pre = chipPreLines();
|
|
401
|
-
const mergedRow = pre[pre.length - 1] ?? '';
|
|
402
|
-
const prefix = chipPrefix();
|
|
403
|
-
// mergedRow 起始到 lines[0] 起始的字符数 = (chipPreLast + chipPrefix) 字符数
|
|
404
|
-
const chipOverheadChars = mergedRow.length + prefix.length - lineText.length;
|
|
405
|
-
ccInLine = ccInLine - chipOverheadChars;
|
|
406
|
-
}
|
|
407
|
-
newCc = Math.max(0, Math.min(ccInLine, lineText.length));
|
|
389
|
+
segStartChars += segsVis[s]?.length ?? 0;
|
|
390
|
+
charInLine = Math.max(0, Math.min(segStartChars + inChar, dispLs[dl]?.length ?? 0));
|
|
391
|
+
dispLine = dl;
|
|
408
392
|
}
|
|
409
|
-
|
|
410
|
-
|
|
393
|
+
const { seg, off } = mapDispToCursor(dispLine, charInLine);
|
|
394
|
+
curSeg = seg;
|
|
395
|
+
curOff = off;
|
|
411
396
|
// 收起菜单/过滤态:点击输入框关闭菜单,回到纯文本编辑。
|
|
412
397
|
if (menuOpen) {
|
|
413
398
|
menuOpen = false;
|
|
@@ -454,18 +439,18 @@ export async function promptWithSlashMenu(opts) {
|
|
|
454
439
|
if (!menuOpen || !item)
|
|
455
440
|
return submitLeaf;
|
|
456
441
|
if (item.node.children?.length) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
442
|
+
segs = [{ frozen: false, text: `${item.input} ` }];
|
|
443
|
+
curSeg = 0;
|
|
444
|
+
curOff = segs[0].text.length;
|
|
460
445
|
selected = 0;
|
|
461
446
|
menuTop = 0;
|
|
462
447
|
computeFiltered();
|
|
463
448
|
redraw();
|
|
464
449
|
return false;
|
|
465
450
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
451
|
+
segs = [{ frozen: false, text: item.node.value ?? item.input }];
|
|
452
|
+
curSeg = 0;
|
|
453
|
+
curOff = segs[0].text.length;
|
|
469
454
|
computeFiltered();
|
|
470
455
|
if (!submitLeaf || item.node.submit === false) {
|
|
471
456
|
redraw();
|
|
@@ -473,44 +458,85 @@ export async function promptWithSlashMenu(opts) {
|
|
|
473
458
|
}
|
|
474
459
|
return true;
|
|
475
460
|
}
|
|
476
|
-
/**
|
|
461
|
+
/** 提交:菜单打开时先应用选中项;分支进入子菜单,需要参数的叶子只补全。
|
|
462
|
+
* 段序列直接按原文(可编辑段原文 + 粘贴块原文)拼接——所见即所得,不再额外插换行。 */
|
|
477
463
|
function submit() {
|
|
478
464
|
if (menuOpen && !applySelected(true))
|
|
479
465
|
return;
|
|
480
|
-
const
|
|
481
|
-
let content = suffix;
|
|
482
|
-
if (chip) {
|
|
483
|
-
content = suffix.length > 0 ? chip + '\n' + suffix : chip;
|
|
484
|
-
if (chipPre)
|
|
485
|
-
content = chipPre + '\n' + content;
|
|
486
|
-
}
|
|
466
|
+
const content = segs.map((s) => s.text).join('');
|
|
487
467
|
finish(content === '' ? [''] : content.split('\n'));
|
|
488
468
|
}
|
|
489
|
-
/**
|
|
469
|
+
/** 插换行:在光标处断行(落进当前可编辑段)。 */
|
|
490
470
|
function insertNewline() {
|
|
491
|
-
|
|
492
|
-
lines[cl] = lines[cl].slice(0, cc);
|
|
493
|
-
lines.splice(cl + 1, 0, after);
|
|
494
|
-
cl++;
|
|
495
|
-
cc = 0;
|
|
471
|
+
insertText('\n');
|
|
496
472
|
computeFiltered();
|
|
497
473
|
redraw();
|
|
498
474
|
}
|
|
499
|
-
/**
|
|
475
|
+
/** 光标移到当前可编辑段内的行首/行尾(不含段内 \n)。 */
|
|
476
|
+
function moveToLineStart() {
|
|
477
|
+
const s = segs[curSeg].text;
|
|
478
|
+
curOff = s.lastIndexOf('\n', curOff - 1) + 1;
|
|
479
|
+
redraw();
|
|
480
|
+
}
|
|
481
|
+
function moveToLineEnd() {
|
|
482
|
+
const s = segs[curSeg].text;
|
|
483
|
+
const ne = s.indexOf('\n', curOff);
|
|
484
|
+
curOff = ne < 0 ? s.length : ne;
|
|
485
|
+
redraw();
|
|
486
|
+
}
|
|
487
|
+
/** Ctrl+U:删到行首(当前可编辑段内的当前行;已在行首则 no-op,不跨入相邻粘贴块)。 */
|
|
488
|
+
function deleteToLineStart() {
|
|
489
|
+
const s = segs[curSeg].text;
|
|
490
|
+
const ls = s.lastIndexOf('\n', curOff - 1) + 1;
|
|
491
|
+
if (ls === curOff)
|
|
492
|
+
return;
|
|
493
|
+
segs[curSeg].text = s.slice(0, ls) + s.slice(curOff);
|
|
494
|
+
curOff = ls;
|
|
495
|
+
computeFiltered();
|
|
496
|
+
redraw();
|
|
497
|
+
}
|
|
498
|
+
/** Ctrl+K:删到行尾(当前可编辑段内的当前行;已在行尾则 no-op,不跨入相邻粘贴块)。 */
|
|
499
|
+
function deleteToLineEnd() {
|
|
500
|
+
const s = segs[curSeg].text;
|
|
501
|
+
const ne = s.indexOf('\n', curOff);
|
|
502
|
+
if (ne < 0) {
|
|
503
|
+
if (curOff === s.length)
|
|
504
|
+
return;
|
|
505
|
+
segs[curSeg].text = s.slice(0, curOff);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
segs[curSeg].text = s.slice(0, curOff) + s.slice(ne + 1);
|
|
509
|
+
}
|
|
510
|
+
computeFiltered();
|
|
511
|
+
redraw();
|
|
512
|
+
}
|
|
513
|
+
/** Ctrl+Z:还原被 Ctrl+C 清掉的输入(单次)。 */
|
|
514
|
+
function restoreUndo() {
|
|
515
|
+
const snap = undoSnapshot;
|
|
516
|
+
if (!snap)
|
|
517
|
+
return;
|
|
518
|
+
segs = snap.segs.map((s) => ({ ...s }));
|
|
519
|
+
curSeg = Math.min(snap.curSeg, segs.length - 1);
|
|
520
|
+
curOff = Math.min(snap.curOff, segs[curSeg].text.length);
|
|
521
|
+
undoSnapshot = null;
|
|
522
|
+
computeFiltered();
|
|
523
|
+
redraw();
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* 清空输入(含粘贴块 / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。
|
|
527
|
+
* 清空前留一份快照供 Ctrl+Z 还原——手滑按在输入框上时,正在写的长 prompt 不至于白打。
|
|
528
|
+
*/
|
|
500
529
|
function clearInput() {
|
|
501
530
|
if (layout.isScrolled())
|
|
502
531
|
layout.resetScroll(); // 回尾(若滚动回看),再清空
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
chipPre = '';
|
|
532
|
+
undoSnapshot = { segs: segs.map((s) => ({ ...s })), curSeg, curOff };
|
|
533
|
+
segs = [{ frozen: false, text: '' }];
|
|
534
|
+
curSeg = 0;
|
|
535
|
+
curOff = 0;
|
|
508
536
|
menuOpen = false;
|
|
509
537
|
filtered = [];
|
|
510
538
|
selected = 0;
|
|
511
539
|
menuTop = 0;
|
|
512
|
-
historyIndex = queryHistory.length;
|
|
513
|
-
historyDraft = null;
|
|
514
540
|
if (pasteTimer) {
|
|
515
541
|
clearTimeout(pasteTimer);
|
|
516
542
|
pasteTimer = null;
|
|
@@ -521,13 +547,10 @@ export async function promptWithSlashMenu(opts) {
|
|
|
521
547
|
computeFiltered();
|
|
522
548
|
redraw();
|
|
523
549
|
}
|
|
524
|
-
/** Ctrl+C:有内容(已打字 / 多行 /
|
|
550
|
+
/** Ctrl+C:有内容(已打字 / 多行 / 粘贴块 / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish)。 */
|
|
525
551
|
function onCtrlC() {
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
lines.some((l) => l !== '') ||
|
|
529
|
-
pasteParts.length > 0 ||
|
|
530
|
-
pasting;
|
|
552
|
+
const flat = segs.map((s) => s.text).join('');
|
|
553
|
+
const hasContent = flat.length > 0 || menuOpen || pasteParts.length > 0 || pasting;
|
|
531
554
|
if (hasContent) {
|
|
532
555
|
clearInput();
|
|
533
556
|
return;
|
|
@@ -574,7 +597,7 @@ export async function promptWithSlashMenu(opts) {
|
|
|
574
597
|
return;
|
|
575
598
|
}
|
|
576
599
|
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
|
|
577
|
-
// 裸 ↑/↓
|
|
600
|
+
// 裸 ↑/↓ 优先给斜杠菜单与多行光标,二者都不适用时按滚轮量滚动回看(与鼠标滚轮同一入口)。
|
|
578
601
|
if (key.name === 'pageup' ||
|
|
579
602
|
key.name === 'pagedown' ||
|
|
580
603
|
(key.ctrl && (key.name === 'up' || key.name === 'down'))) {
|
|
@@ -592,18 +615,38 @@ export async function promptWithSlashMenu(opts) {
|
|
|
592
615
|
// 滚动回看时打字 / 编辑不回尾(保持历史视图,便于边看历史边编辑);仅 Enter 提交时回尾(见下方 submit 前)。
|
|
593
616
|
// Ctrl+C 已在 onKey 顶部统一处理(清空或退出);Ctrl+D:空缓冲退出,否则忽略
|
|
594
617
|
if (key.ctrl && key.name === 'd') {
|
|
595
|
-
if (
|
|
618
|
+
if (segs.map((s) => s.text).join('') === '')
|
|
596
619
|
finish(null);
|
|
597
620
|
return;
|
|
598
621
|
}
|
|
599
622
|
if (key.ctrl && key.name === 'a') {
|
|
600
|
-
|
|
601
|
-
|
|
623
|
+
moveToLineStart();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (key.ctrl && key.name === 'q') {
|
|
627
|
+
// 行首的对称搭档:与 Ctrl+E(行尾)同处顶行相邻(Q/E),左=起始右=结束。
|
|
628
|
+
// Ctrl+A 仍作别名保留(全 app 一致:运行中预填、权限面板都用 Ctrl+A 跳开头)。
|
|
629
|
+
moveToLineStart();
|
|
602
630
|
return;
|
|
603
631
|
}
|
|
604
632
|
if (key.ctrl && key.name === 'e') {
|
|
605
|
-
|
|
606
|
-
|
|
633
|
+
moveToLineEnd();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// Ctrl+Z:还原被 Ctrl+C 清掉的输入。无快照时 no-op。
|
|
637
|
+
if (key.ctrl && key.name === 'z') {
|
|
638
|
+
restoreUndo();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// 行级删除:Ctrl+U 删到行首,Ctrl+K 删到行尾(均在当前可编辑段内,不跨入相邻粘贴块)。
|
|
642
|
+
// 词级删除/跳转(Ctrl+W / Ctrl+← 等)刻意不做:终端序列各终端发得不一样
|
|
643
|
+
// (Ctrl+Backspace 在多数终端发 \x08,与裸退格无从区分),做一半会变成按了没反应的哑键。
|
|
644
|
+
if (key.ctrl && key.name === 'u') {
|
|
645
|
+
deleteToLineStart();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (key.ctrl && key.name === 'k') {
|
|
649
|
+
deleteToLineEnd();
|
|
607
650
|
return;
|
|
608
651
|
}
|
|
609
652
|
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
@@ -629,48 +672,71 @@ export async function promptWithSlashMenu(opts) {
|
|
|
629
672
|
return;
|
|
630
673
|
}
|
|
631
674
|
switch (key.name) {
|
|
632
|
-
case 'backspace':
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
675
|
+
case 'backspace': {
|
|
676
|
+
const s = segs[curSeg].text;
|
|
677
|
+
if (curOff > 0) {
|
|
678
|
+
segs[curSeg].text = s.slice(0, curOff - 1) + s.slice(curOff);
|
|
679
|
+
curOff--;
|
|
636
680
|
computeFiltered();
|
|
637
681
|
redraw();
|
|
638
682
|
}
|
|
639
|
-
else if (
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
683
|
+
else if (curSeg > 0) {
|
|
684
|
+
const prev = segs[curSeg - 1];
|
|
685
|
+
if (prev.frozen) {
|
|
686
|
+
// 紧贴粘贴块后沿退格:删整块(原子)
|
|
687
|
+
segs.splice(curSeg - 1, 1);
|
|
688
|
+
curSeg--;
|
|
689
|
+
curOff = 0;
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
// 并回上一个可编辑段
|
|
693
|
+
prev.text += segs[curSeg].text;
|
|
694
|
+
const joinOff = prev.text.length - segs[curSeg].text.length;
|
|
695
|
+
segs.splice(curSeg, 1);
|
|
696
|
+
curSeg--;
|
|
697
|
+
curOff = joinOff;
|
|
698
|
+
}
|
|
646
699
|
computeFiltered();
|
|
647
700
|
redraw();
|
|
648
701
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
case 'delete': {
|
|
705
|
+
const s = segs[curSeg].text;
|
|
706
|
+
if (curOff < s.length) {
|
|
707
|
+
segs[curSeg].text = s.slice(0, curOff) + s.slice(curOff + 1);
|
|
708
|
+
computeFiltered();
|
|
709
|
+
redraw();
|
|
710
|
+
}
|
|
711
|
+
else if (curSeg < segs.length - 1) {
|
|
712
|
+
const next = segs[curSeg + 1];
|
|
713
|
+
if (next.frozen)
|
|
714
|
+
segs.splice(curSeg + 1, 1); // 并掉下一个粘贴块
|
|
715
|
+
else {
|
|
716
|
+
segs[curSeg].text += next.text;
|
|
717
|
+
segs.splice(curSeg + 1, 1);
|
|
718
|
+
}
|
|
658
719
|
computeFiltered();
|
|
659
720
|
redraw();
|
|
660
721
|
}
|
|
661
722
|
return;
|
|
723
|
+
}
|
|
662
724
|
case 'up':
|
|
663
725
|
if (menuOpen && filtered.length) {
|
|
664
726
|
selected = (selected - 1 + filtered.length) % filtered.length;
|
|
665
727
|
redraw();
|
|
666
728
|
}
|
|
667
|
-
else if (cl > 0) {
|
|
668
|
-
cl--;
|
|
669
|
-
cc = Math.min(cc, lines[cl].length);
|
|
670
|
-
redraw();
|
|
671
|
-
}
|
|
672
729
|
else {
|
|
673
|
-
|
|
730
|
+
const { line, col } = cursorDisp();
|
|
731
|
+
if (line > 0) {
|
|
732
|
+
const m = mapDispToCursor(line - 1, dispColToCharInLine(line - 1, col));
|
|
733
|
+
curSeg = m.seg;
|
|
734
|
+
curOff = m.off;
|
|
735
|
+
redraw();
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
layout.scrollWheel(1); // 等同鼠标滚轮上滚一格
|
|
739
|
+
}
|
|
674
740
|
}
|
|
675
741
|
return;
|
|
676
742
|
case 'down':
|
|
@@ -678,13 +744,18 @@ export async function promptWithSlashMenu(opts) {
|
|
|
678
744
|
selected = (selected + 1) % filtered.length;
|
|
679
745
|
redraw();
|
|
680
746
|
}
|
|
681
|
-
else if (cl < lines.length - 1) {
|
|
682
|
-
cl++;
|
|
683
|
-
cc = Math.min(cc, lines[cl].length);
|
|
684
|
-
redraw();
|
|
685
|
-
}
|
|
686
747
|
else {
|
|
687
|
-
|
|
748
|
+
const { lines: dispLines } = buildDisplay();
|
|
749
|
+
const { line, col } = cursorDisp();
|
|
750
|
+
if (line < dispLines.length - 1) {
|
|
751
|
+
const m = mapDispToCursor(line + 1, dispColToCharInLine(line + 1, col));
|
|
752
|
+
curSeg = m.seg;
|
|
753
|
+
curOff = m.off;
|
|
754
|
+
redraw();
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
layout.scrollWheel(-1); // 等同鼠标滚轮下滚一格
|
|
758
|
+
}
|
|
688
759
|
}
|
|
689
760
|
return;
|
|
690
761
|
case 'tab':
|
|
@@ -692,12 +763,12 @@ export async function promptWithSlashMenu(opts) {
|
|
|
692
763
|
applySelected(false);
|
|
693
764
|
return;
|
|
694
765
|
case 'escape': {
|
|
695
|
-
const { prefix } = menuContext(lines[0]);
|
|
766
|
+
const { prefix } = menuContext(buildDisplay().lines[0] ?? '');
|
|
696
767
|
if (prefix) {
|
|
697
768
|
// 子层 Esc 回到父节点;例如 /model sw → /model。
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
769
|
+
segs = [{ frozen: false, text: prefix.trimEnd() }];
|
|
770
|
+
curSeg = 0;
|
|
771
|
+
curOff = segs[0].text.length;
|
|
701
772
|
selected = 0;
|
|
702
773
|
menuTop = 0;
|
|
703
774
|
computeFiltered();
|
|
@@ -711,42 +782,52 @@ export async function promptWithSlashMenu(opts) {
|
|
|
711
782
|
redraw();
|
|
712
783
|
return;
|
|
713
784
|
}
|
|
714
|
-
case 'left':
|
|
715
|
-
if (
|
|
716
|
-
|
|
785
|
+
case 'left': {
|
|
786
|
+
if (curOff > 0) {
|
|
787
|
+
curOff--;
|
|
717
788
|
redraw();
|
|
718
789
|
}
|
|
719
|
-
else if (
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
790
|
+
else if (curSeg > 0) {
|
|
791
|
+
let p = curSeg - 1;
|
|
792
|
+
while (p >= 0 && segs[p].frozen)
|
|
793
|
+
p--;
|
|
794
|
+
if (p >= 0) {
|
|
795
|
+
curSeg = p;
|
|
796
|
+
curOff = segs[p].text.length;
|
|
797
|
+
redraw();
|
|
798
|
+
}
|
|
723
799
|
}
|
|
724
800
|
return;
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
801
|
+
}
|
|
802
|
+
case 'right': {
|
|
803
|
+
const s = segs[curSeg].text;
|
|
804
|
+
if (curOff < s.length) {
|
|
805
|
+
curOff++;
|
|
728
806
|
redraw();
|
|
729
807
|
}
|
|
730
|
-
else if (
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
808
|
+
else if (curSeg < segs.length - 1) {
|
|
809
|
+
let nx = curSeg + 1;
|
|
810
|
+
while (nx < segs.length && segs[nx].frozen)
|
|
811
|
+
nx++;
|
|
812
|
+
if (nx < segs.length) {
|
|
813
|
+
curSeg = nx;
|
|
814
|
+
curOff = 0;
|
|
815
|
+
redraw();
|
|
816
|
+
}
|
|
734
817
|
}
|
|
735
818
|
return;
|
|
819
|
+
}
|
|
736
820
|
case 'home':
|
|
737
|
-
|
|
738
|
-
redraw();
|
|
821
|
+
moveToLineStart();
|
|
739
822
|
return;
|
|
740
823
|
case 'end':
|
|
741
|
-
|
|
742
|
-
redraw();
|
|
824
|
+
moveToLineEnd();
|
|
743
825
|
return;
|
|
744
826
|
}
|
|
745
827
|
// 可打印字符(>= 空格,非 ctrl/meta)
|
|
746
828
|
const s = key.sequence ?? '';
|
|
747
829
|
if (s && s >= ' ' && !key.ctrl && !key.meta) {
|
|
748
|
-
|
|
749
|
-
cc += s.length;
|
|
830
|
+
insertText(s);
|
|
750
831
|
computeFiltered();
|
|
751
832
|
redraw();
|
|
752
833
|
}
|