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.
@@ -255,7 +255,11 @@ onContextUpdate) {
255
255
  // 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
256
256
  // 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
257
257
  const spinner = new Spinner((msg, frame) => {
258
- layout.setStatus(frame ? `${msg}…` : '', frame ?? undefined);
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';镜像与此同步。
@@ -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';
@@ -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) {
@@ -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
- let selected = 0;
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
- scrollBy(e.dir * WHEEL_LINES);
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
- // 跟非回看的 INPUT/RUNNING 态保持一致——避免「● 留下、状态字蒸发」的视觉错觉。
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
- lead = `${symbol} ${ui.dim}${status.status}${ui.reset}`;
1366
- leadW = 1 + 1 + displayWidth(status.status);
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
- let lines = opts.initialLines && opts.initialLines.length > 0
78
- ? [...opts.initialLines]
79
- : [''];
80
- let cl = lines.length - 1; // 光标行(0-based)= 末行
81
- let cc = lines[cl].length; // 光标在该行的字符索引 = 末行末尾
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
- /** 当前光标在该行的显示列( layout 定位光标)。 */
184
- function cursorCol() {
185
- return displayWidth(lines[cl].slice(0, cc));
186
- }
187
- /** chip 预览前缀:行数 + 字符数(字符 <1K 直出,≥1K 缩为 X.XK)。比"前 20 字符截断"对 CJK 更友好
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: chars < 1000 ? chars : `${(chars / 1000).toFixed(1)}K`,
137
+ count: charCount < 1000 ? charCount : `${(charCount / 1000).toFixed(1)}K`,
197
138
  });
198
139
  return `[📋 ${lineText} · ${charText}] `;
199
140
  }
200
- /** chipPre 按行拆分(粘贴发生前光标之前已有的文本,可能多行,原样保留在 chip 之前) */
201
- function chipPreLines() {
202
- return chip ? chipPre.split('\n') : [];
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
- /** layout 画的行:chipPre 的行原样排在前面,最后一行接上 chip 前缀 + suffix 第 0 行
205
- * (chip 原子显示,不可编辑;chipPre 同样不可直接编辑,光标只活在 suffix) */
206
- function dispLines() {
207
- if (!chip)
208
- return lines;
209
- const pre = chipPreLines();
210
- const lastPre = pre[pre.length - 1];
211
- const mergedRow = lastPre + chipPrefix() + lines[0];
212
- return [...pre.slice(0, -1), mergedRow, ...lines.slice(1)];
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
- /** layout 定位光标行:chipPre 多出的行数计入偏移(suffix cl=0 对应 chipPre 最后一行所在的合并行)。 */
215
- function dispCursorLine() {
216
- return chip ? chipPreLines().length - 1 + cl : cl;
200
+ /** 展示 (行, 显示列) 段内字符偏移。供 ↑/↓ 移动后重新定位,以及鼠标点击的反向映射。 */
201
+ function dispColToCharInLine(dispLine, dispCol) {
202
+ const { lines } = buildDisplay();
203
+ const ln = lines[dispLine] ?? '';
204
+ return visColToCharCol(ln, dispCol);
217
205
  }
218
- /** layout 定位光标列:chip 在合并行(cl===0)时偏移 chipPre 末行宽度 + chipPrefix 宽度。 */
219
- function dispCursorCol() {
220
- if (!chip || cl !== 0)
221
- return cursorCol();
222
- const pre = chipPreLines();
223
- return displayWidth(pre[pre.length - 1]) + displayWidth(chipPrefix()) + cursorCol();
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
- /** 在光标处插入文本(含换行则拆行)。供短粘贴 finalize 落为可编辑文本。 */
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 parts = text.split('\n');
228
- const before = lines[cl].slice(0, cc);
229
- const after = lines[cl].slice(cc);
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
- /** 落一段粘贴文本(长/短判定与 chip 逻辑,finalizePaste 与鼠标点击贴入共用)
241
- * 长粘贴落 chip 时不再吞掉用户已打的字:光标前的文本并入 chipPre(原样保留,与 chip 一起原子化),
242
- * 光标后的文本留在 suffix(lines)——即"前段文字 [长文本…] 后段文字"而非清空覆盖。 */
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
- if (cl === 0 && lines[0].startsWith('/')) {
301
- const { nodes, prefix } = menuContext(lines[0]);
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(lines[0]));
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: dispLines(),
321
- cursorLine: dispCursorLine(),
322
- cursorCol: dispCursorCol(),
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
- * 本函数在 prompt 自己的 (lines + chip) 上复刻 wrap/flat 还原 (cl, cc)→ redraw。
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 = dispLines();
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 newCl;
345
- let newCc;
363
+ let dispLine;
364
+ let charInLine;
346
365
  if (flat.length === 0) {
347
- newCl = 0;
348
- newCc = 0;
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 dispLine = 0;
376
+ let dl = 0;
358
377
  let segInLine = safeIdx;
359
- while (dispLine < lineVis.length && segInLine >= lineVis[dispLine].length) {
360
- segInLine -= lineVis[dispLine].length;
361
- dispLine++;
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
- else {
383
- newCl = dispLine;
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 segs = lineVis[dispLine];
387
+ const segsVis = lineVis[dl];
396
388
  for (let s = 0; s < segInLine; s++)
397
- segStartChars += segs[s]?.length ?? 0;
398
- let ccInLine = segStartChars + inChar;
399
- if (chip && newCl === 0) {
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
- cl = newCl;
410
- cc = newCc;
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
- lines = [`${item.input} `];
458
- cl = 0;
459
- cc = lines[0].length;
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
- lines = [item.node.value ?? item.input];
467
- cl = 0;
468
- cc = lines[0].length;
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 suffix = lines.join('\n');
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
- const after = lines[cl].slice(cc);
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
- /** 清空输入( chip / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。 */
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
- lines = [''];
504
- cl = 0;
505
- cc = 0;
506
- chip = null;
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:有内容(已打字 / 多行 / chip / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish)。 */
550
+ /** Ctrl+C:有内容(已打字 / 多行 / 粘贴块 / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish)。 */
525
551
  function onCtrlC() {
526
- const hasContent = chip != null ||
527
- lines.length > 1 ||
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
- // 裸 ↑/↓ 留给斜杠菜单、多行光标和 query 历史导航;鼠标滚轮由 SGR mouse 事件处理。
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 (lines.every((l) => l === '') && cl === 0 && cc === 0)
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
- cc = 0;
601
- redraw();
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
- cc = lines[cl].length;
606
- redraw();
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
- if (cc > 0) {
634
- lines[cl] = lines[cl].slice(0, cc - 1) + lines[cl].slice(cc);
635
- cc--;
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 (cl > 0) {
640
- // 行首退格:并入上一行
641
- const cur = lines[cl];
642
- cc = lines[cl - 1].length;
643
- lines[cl - 1] = lines[cl - 1] + cur;
644
- lines.splice(cl, 1);
645
- cl--;
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
- else if (chip) {
650
- // 光标在 suffix 开头(紧贴 ] 后):退格删整个 chip(原子),chipPre 还原为可编辑行,
651
- // 光标落在 chipPre 末尾(紧接原来打的字继续编辑)。
652
- const preLines = chipPre ? chipPre.split('\n') : [''];
653
- lines = [...preLines.slice(0, -1), preLines[preLines.length - 1] + lines[0], ...lines.slice(1)];
654
- cl = preLines.length - 1;
655
- cc = preLines[preLines.length - 1].length;
656
- chip = null;
657
- chipPre = '';
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
- recallPrevious();
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
- recallNext();
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
- lines = [prefix.trimEnd()];
699
- cl = 0;
700
- cc = lines[0].length;
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 (cc > 0) {
716
- cc--;
785
+ case 'left': {
786
+ if (curOff > 0) {
787
+ curOff--;
717
788
  redraw();
718
789
  }
719
- else if (cl > 0) {
720
- cl--;
721
- cc = lines[cl].length;
722
- redraw();
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
- case 'right':
726
- if (cc < lines[cl].length) {
727
- cc++;
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 (cl < lines.length - 1) {
731
- cl++;
732
- cc = 0;
733
- redraw();
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
- cc = 0;
738
- redraw();
821
+ moveToLineStart();
739
822
  return;
740
823
  case 'end':
741
- cc = lines[cl].length;
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
- lines[cl] = lines[cl].slice(0, cc) + s + lines[cl].slice(cc);
749
- cc += s.length;
830
+ insertText(s);
750
831
  computeFiltered();
751
832
  redraw();
752
833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.2.9",
3
+ "version": "1.3.0",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {