mocode-ai 1.2.10 → 1.3.1

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/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,38 @@ export async function promptWithSlashMenu(opts) {
315
337
  }
316
338
  }
317
339
  function redraw() {
340
+ const { lines } = buildDisplay();
341
+ const cur = cursorDisp();
342
+ // 空输入引导:缓冲为空时输入框内画 dim ghost 占位(含 /help 提示),让用户知道怎么开始;
343
+ // 一旦有输入(打字/粘贴/补全)isEmpty 变 false,下一帧占位即消失。
344
+ const isEmpty = segs.length === 1 && !segs[0].frozen && segs[0].text === '';
318
345
  layout.paintInput({
319
346
  prompt: opts.prompt,
320
- lines: dispLines(),
321
- cursorLine: dispCursorLine(),
322
- cursorCol: dispCursorCol(),
347
+ lines,
348
+ cursorLine: cur.line,
349
+ cursorCol: cur.col,
323
350
  menu: menuLines().length ? { lines: menuLines() } : null,
351
+ placeholder: isEmpty ? t('prompt.emptyPlaceholder') : undefined,
324
352
  });
325
353
  }
326
354
  /** 鼠标点击输入框: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 字符数。 */
355
+ * (flatIdx = flat 中的绝对段号;inSegVis = 段内显示列)。本函数把点击位还原成 (段, 偏移) → redraw
356
+ * 点中只读粘贴块时吸附到最近的可编辑边界( mapDispToCursor) */
334
357
  function applyExternalCursor(flatIdx, inSegVis) {
335
358
  const g = layout.getGeo();
336
359
  const promptW = displayWidth(opts.prompt);
337
360
  const W = Math.max(1, g.cols - promptW);
338
- const dispLs = dispLines();
361
+ const { lines: dispLs } = buildDisplay();
339
362
  const lineVis = dispLs.map((l) => wrapByDisplayWidth(l, W));
340
363
  const flat = [];
341
364
  for (const lv of lineVis)
342
365
  for (const r of lv)
343
366
  flat.push(r);
344
- let newCl;
345
- let newCc;
367
+ let dispLine;
368
+ let charInLine;
346
369
  if (flat.length === 0) {
347
- newCl = 0;
348
- newCc = 0;
370
+ dispLine = 0;
371
+ charInLine = 0;
349
372
  }
350
373
  else {
351
374
  const safeIdx = Math.max(0, Math.min(flatIdx, flat.length - 1));
@@ -354,60 +377,26 @@ export async function promptWithSlashMenu(opts) {
354
377
  const safeInSeg = Math.max(0, Math.min(inSegVis, segW));
355
378
  const inChar = visColToCharCol(seg, safeInSeg);
356
379
  // safeIdx → (dispLine, segInLine)
357
- let dispLine = 0;
380
+ let dl = 0;
358
381
  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
- }
382
+ while (dl < lineVis.length && segInLine >= lineVis[dl].length) {
383
+ segInLine -= lineVis[dl].length;
384
+ dl++;
381
385
  }
382
- else {
383
- newCl = dispLine;
386
+ if (dl >= lineVis.length) {
387
+ dl = lineVis.length - 1;
388
+ segInLine = lineVis[dl].length - 1;
384
389
  }
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
390
  let segStartChars = 0;
395
- const segs = lineVis[dispLine];
391
+ const segsVis = lineVis[dl];
396
392
  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));
393
+ segStartChars += segsVis[s]?.length ?? 0;
394
+ charInLine = Math.max(0, Math.min(segStartChars + inChar, dispLs[dl]?.length ?? 0));
395
+ dispLine = dl;
408
396
  }
409
- cl = newCl;
410
- cc = newCc;
397
+ const { seg, off } = mapDispToCursor(dispLine, charInLine);
398
+ curSeg = seg;
399
+ curOff = off;
411
400
  // 收起菜单/过滤态:点击输入框关闭菜单,回到纯文本编辑。
412
401
  if (menuOpen) {
413
402
  menuOpen = false;
@@ -454,18 +443,18 @@ export async function promptWithSlashMenu(opts) {
454
443
  if (!menuOpen || !item)
455
444
  return submitLeaf;
456
445
  if (item.node.children?.length) {
457
- lines = [`${item.input} `];
458
- cl = 0;
459
- cc = lines[0].length;
446
+ segs = [{ frozen: false, text: `${item.input} ` }];
447
+ curSeg = 0;
448
+ curOff = segs[0].text.length;
460
449
  selected = 0;
461
450
  menuTop = 0;
462
451
  computeFiltered();
463
452
  redraw();
464
453
  return false;
465
454
  }
466
- lines = [item.node.value ?? item.input];
467
- cl = 0;
468
- cc = lines[0].length;
455
+ segs = [{ frozen: false, text: item.node.value ?? item.input }];
456
+ curSeg = 0;
457
+ curOff = segs[0].text.length;
469
458
  computeFiltered();
470
459
  if (!submitLeaf || item.node.submit === false) {
471
460
  redraw();
@@ -473,44 +462,85 @@ export async function promptWithSlashMenu(opts) {
473
462
  }
474
463
  return true;
475
464
  }
476
- /** 提交:菜单打开时先应用选中项;分支进入子菜单,需要参数的叶子只补全。 */
465
+ /** 提交:菜单打开时先应用选中项;分支进入子菜单,需要参数的叶子只补全。
466
+ * 段序列直接按原文(可编辑段原文 + 粘贴块原文)拼接——所见即所得,不再额外插换行。 */
477
467
  function submit() {
478
468
  if (menuOpen && !applySelected(true))
479
469
  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
- }
470
+ const content = segs.map((s) => s.text).join('');
487
471
  finish(content === '' ? [''] : content.split('\n'));
488
472
  }
489
- /** 插换行:在光标处断行。 */
473
+ /** 插换行:在光标处断行(落进当前可编辑段)。 */
490
474
  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;
475
+ insertText('\n');
496
476
  computeFiltered();
497
477
  redraw();
498
478
  }
499
- /** 清空输入( chip / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。 */
479
+ /** 光标移到当前可编辑段内的行首/行尾(不含段内 \n) */
480
+ function moveToLineStart() {
481
+ const s = segs[curSeg].text;
482
+ curOff = s.lastIndexOf('\n', curOff - 1) + 1;
483
+ redraw();
484
+ }
485
+ function moveToLineEnd() {
486
+ const s = segs[curSeg].text;
487
+ const ne = s.indexOf('\n', curOff);
488
+ curOff = ne < 0 ? s.length : ne;
489
+ redraw();
490
+ }
491
+ /** Ctrl+U:删到行首(当前可编辑段内的当前行;已在行首则 no-op,不跨入相邻粘贴块)。 */
492
+ function deleteToLineStart() {
493
+ const s = segs[curSeg].text;
494
+ const ls = s.lastIndexOf('\n', curOff - 1) + 1;
495
+ if (ls === curOff)
496
+ return;
497
+ segs[curSeg].text = s.slice(0, ls) + s.slice(curOff);
498
+ curOff = ls;
499
+ computeFiltered();
500
+ redraw();
501
+ }
502
+ /** Ctrl+K:删到行尾(当前可编辑段内的当前行;已在行尾则 no-op,不跨入相邻粘贴块)。 */
503
+ function deleteToLineEnd() {
504
+ const s = segs[curSeg].text;
505
+ const ne = s.indexOf('\n', curOff);
506
+ if (ne < 0) {
507
+ if (curOff === s.length)
508
+ return;
509
+ segs[curSeg].text = s.slice(0, curOff);
510
+ }
511
+ else {
512
+ segs[curSeg].text = s.slice(0, curOff) + s.slice(ne + 1);
513
+ }
514
+ computeFiltered();
515
+ redraw();
516
+ }
517
+ /** Ctrl+Z:还原被 Ctrl+C 清掉的输入(单次)。 */
518
+ function restoreUndo() {
519
+ const snap = undoSnapshot;
520
+ if (!snap)
521
+ return;
522
+ segs = snap.segs.map((s) => ({ ...s }));
523
+ curSeg = Math.min(snap.curSeg, segs.length - 1);
524
+ curOff = Math.min(snap.curOff, segs[curSeg].text.length);
525
+ undoSnapshot = null;
526
+ computeFiltered();
527
+ redraw();
528
+ }
529
+ /**
530
+ * 清空输入(含粘贴块 / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。
531
+ * 清空前留一份快照供 Ctrl+Z 还原——手滑按在输入框上时,正在写的长 prompt 不至于白打。
532
+ */
500
533
  function clearInput() {
501
534
  if (layout.isScrolled())
502
535
  layout.resetScroll(); // 回尾(若滚动回看),再清空
503
- lines = [''];
504
- cl = 0;
505
- cc = 0;
506
- chip = null;
507
- chipPre = '';
536
+ undoSnapshot = { segs: segs.map((s) => ({ ...s })), curSeg, curOff };
537
+ segs = [{ frozen: false, text: '' }];
538
+ curSeg = 0;
539
+ curOff = 0;
508
540
  menuOpen = false;
509
541
  filtered = [];
510
542
  selected = 0;
511
543
  menuTop = 0;
512
- historyIndex = queryHistory.length;
513
- historyDraft = null;
514
544
  if (pasteTimer) {
515
545
  clearTimeout(pasteTimer);
516
546
  pasteTimer = null;
@@ -521,13 +551,10 @@ export async function promptWithSlashMenu(opts) {
521
551
  computeFiltered();
522
552
  redraw();
523
553
  }
524
- /** Ctrl+C:有内容(已打字 / 多行 / chip / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish)。 */
554
+ /** Ctrl+C:有内容(已打字 / 多行 / 粘贴块 / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish)。 */
525
555
  function onCtrlC() {
526
- const hasContent = chip != null ||
527
- lines.length > 1 ||
528
- lines.some((l) => l !== '') ||
529
- pasteParts.length > 0 ||
530
- pasting;
556
+ const flat = segs.map((s) => s.text).join('');
557
+ const hasContent = flat.length > 0 || menuOpen || pasteParts.length > 0 || pasting;
531
558
  if (hasContent) {
532
559
  clearInput();
533
560
  return;
@@ -574,7 +601,7 @@ export async function promptWithSlashMenu(opts) {
574
601
  return;
575
602
  }
576
603
  // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
577
- // 裸 ↑/↓ 留给斜杠菜单、多行光标和 query 历史导航;鼠标滚轮由 SGR mouse 事件处理。
604
+ // 裸 ↑/↓ 优先给斜杠菜单与多行光标,二者都不适用时按滚轮量滚动回看(与鼠标滚轮同一入口)。
578
605
  if (key.name === 'pageup' ||
579
606
  key.name === 'pagedown' ||
580
607
  (key.ctrl && (key.name === 'up' || key.name === 'down'))) {
@@ -592,18 +619,38 @@ export async function promptWithSlashMenu(opts) {
592
619
  // 滚动回看时打字 / 编辑不回尾(保持历史视图,便于边看历史边编辑);仅 Enter 提交时回尾(见下方 submit 前)。
593
620
  // Ctrl+C 已在 onKey 顶部统一处理(清空或退出);Ctrl+D:空缓冲退出,否则忽略
594
621
  if (key.ctrl && key.name === 'd') {
595
- if (lines.every((l) => l === '') && cl === 0 && cc === 0)
622
+ if (segs.map((s) => s.text).join('') === '')
596
623
  finish(null);
597
624
  return;
598
625
  }
599
626
  if (key.ctrl && key.name === 'a') {
600
- cc = 0;
601
- redraw();
627
+ moveToLineStart();
628
+ return;
629
+ }
630
+ if (key.ctrl && key.name === 'q') {
631
+ // 行首的对称搭档:与 Ctrl+E(行尾)同处顶行相邻(Q/E),左=起始右=结束。
632
+ // Ctrl+A 仍作别名保留(全 app 一致:运行中预填、权限面板都用 Ctrl+A 跳开头)。
633
+ moveToLineStart();
602
634
  return;
603
635
  }
604
636
  if (key.ctrl && key.name === 'e') {
605
- cc = lines[cl].length;
606
- redraw();
637
+ moveToLineEnd();
638
+ return;
639
+ }
640
+ // Ctrl+Z:还原被 Ctrl+C 清掉的输入。无快照时 no-op。
641
+ if (key.ctrl && key.name === 'z') {
642
+ restoreUndo();
643
+ return;
644
+ }
645
+ // 行级删除:Ctrl+U 删到行首,Ctrl+K 删到行尾(均在当前可编辑段内,不跨入相邻粘贴块)。
646
+ // 词级删除/跳转(Ctrl+W / Ctrl+← 等)刻意不做:终端序列各终端发得不一样
647
+ // (Ctrl+Backspace 在多数终端发 \x08,与裸退格无从区分),做一半会变成按了没反应的哑键。
648
+ if (key.ctrl && key.name === 'u') {
649
+ deleteToLineStart();
650
+ return;
651
+ }
652
+ if (key.ctrl && key.name === 'k') {
653
+ deleteToLineEnd();
607
654
  return;
608
655
  }
609
656
  const isReturn = key.name === 'return' || key.name === 'enter';
@@ -629,48 +676,71 @@ export async function promptWithSlashMenu(opts) {
629
676
  return;
630
677
  }
631
678
  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--;
679
+ case 'backspace': {
680
+ const s = segs[curSeg].text;
681
+ if (curOff > 0) {
682
+ segs[curSeg].text = s.slice(0, curOff - 1) + s.slice(curOff);
683
+ curOff--;
636
684
  computeFiltered();
637
685
  redraw();
638
686
  }
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--;
687
+ else if (curSeg > 0) {
688
+ const prev = segs[curSeg - 1];
689
+ if (prev.frozen) {
690
+ // 紧贴粘贴块后沿退格:删整块(原子)
691
+ segs.splice(curSeg - 1, 1);
692
+ curSeg--;
693
+ curOff = 0;
694
+ }
695
+ else {
696
+ // 并回上一个可编辑段
697
+ prev.text += segs[curSeg].text;
698
+ const joinOff = prev.text.length - segs[curSeg].text.length;
699
+ segs.splice(curSeg, 1);
700
+ curSeg--;
701
+ curOff = joinOff;
702
+ }
646
703
  computeFiltered();
647
704
  redraw();
648
705
  }
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 = '';
706
+ return;
707
+ }
708
+ case 'delete': {
709
+ const s = segs[curSeg].text;
710
+ if (curOff < s.length) {
711
+ segs[curSeg].text = s.slice(0, curOff) + s.slice(curOff + 1);
712
+ computeFiltered();
713
+ redraw();
714
+ }
715
+ else if (curSeg < segs.length - 1) {
716
+ const next = segs[curSeg + 1];
717
+ if (next.frozen)
718
+ segs.splice(curSeg + 1, 1); // 并掉下一个粘贴块
719
+ else {
720
+ segs[curSeg].text += next.text;
721
+ segs.splice(curSeg + 1, 1);
722
+ }
658
723
  computeFiltered();
659
724
  redraw();
660
725
  }
661
726
  return;
727
+ }
662
728
  case 'up':
663
729
  if (menuOpen && filtered.length) {
664
730
  selected = (selected - 1 + filtered.length) % filtered.length;
665
731
  redraw();
666
732
  }
667
- else if (cl > 0) {
668
- cl--;
669
- cc = Math.min(cc, lines[cl].length);
670
- redraw();
671
- }
672
733
  else {
673
- recallPrevious();
734
+ const { line, col } = cursorDisp();
735
+ if (line > 0) {
736
+ const m = mapDispToCursor(line - 1, dispColToCharInLine(line - 1, col));
737
+ curSeg = m.seg;
738
+ curOff = m.off;
739
+ redraw();
740
+ }
741
+ else {
742
+ layout.scrollWheel(1); // 等同鼠标滚轮上滚一格
743
+ }
674
744
  }
675
745
  return;
676
746
  case 'down':
@@ -678,13 +748,18 @@ export async function promptWithSlashMenu(opts) {
678
748
  selected = (selected + 1) % filtered.length;
679
749
  redraw();
680
750
  }
681
- else if (cl < lines.length - 1) {
682
- cl++;
683
- cc = Math.min(cc, lines[cl].length);
684
- redraw();
685
- }
686
751
  else {
687
- recallNext();
752
+ const { lines: dispLines } = buildDisplay();
753
+ const { line, col } = cursorDisp();
754
+ if (line < dispLines.length - 1) {
755
+ const m = mapDispToCursor(line + 1, dispColToCharInLine(line + 1, col));
756
+ curSeg = m.seg;
757
+ curOff = m.off;
758
+ redraw();
759
+ }
760
+ else {
761
+ layout.scrollWheel(-1); // 等同鼠标滚轮下滚一格
762
+ }
688
763
  }
689
764
  return;
690
765
  case 'tab':
@@ -692,12 +767,12 @@ export async function promptWithSlashMenu(opts) {
692
767
  applySelected(false);
693
768
  return;
694
769
  case 'escape': {
695
- const { prefix } = menuContext(lines[0]);
770
+ const { prefix } = menuContext(buildDisplay().lines[0] ?? '');
696
771
  if (prefix) {
697
772
  // 子层 Esc 回到父节点;例如 /model sw → /model。
698
- lines = [prefix.trimEnd()];
699
- cl = 0;
700
- cc = lines[0].length;
773
+ segs = [{ frozen: false, text: prefix.trimEnd() }];
774
+ curSeg = 0;
775
+ curOff = segs[0].text.length;
701
776
  selected = 0;
702
777
  menuTop = 0;
703
778
  computeFiltered();
@@ -711,42 +786,52 @@ export async function promptWithSlashMenu(opts) {
711
786
  redraw();
712
787
  return;
713
788
  }
714
- case 'left':
715
- if (cc > 0) {
716
- cc--;
789
+ case 'left': {
790
+ if (curOff > 0) {
791
+ curOff--;
717
792
  redraw();
718
793
  }
719
- else if (cl > 0) {
720
- cl--;
721
- cc = lines[cl].length;
722
- redraw();
794
+ else if (curSeg > 0) {
795
+ let p = curSeg - 1;
796
+ while (p >= 0 && segs[p].frozen)
797
+ p--;
798
+ if (p >= 0) {
799
+ curSeg = p;
800
+ curOff = segs[p].text.length;
801
+ redraw();
802
+ }
723
803
  }
724
804
  return;
725
- case 'right':
726
- if (cc < lines[cl].length) {
727
- cc++;
805
+ }
806
+ case 'right': {
807
+ const s = segs[curSeg].text;
808
+ if (curOff < s.length) {
809
+ curOff++;
728
810
  redraw();
729
811
  }
730
- else if (cl < lines.length - 1) {
731
- cl++;
732
- cc = 0;
733
- redraw();
812
+ else if (curSeg < segs.length - 1) {
813
+ let nx = curSeg + 1;
814
+ while (nx < segs.length && segs[nx].frozen)
815
+ nx++;
816
+ if (nx < segs.length) {
817
+ curSeg = nx;
818
+ curOff = 0;
819
+ redraw();
820
+ }
734
821
  }
735
822
  return;
823
+ }
736
824
  case 'home':
737
- cc = 0;
738
- redraw();
825
+ moveToLineStart();
739
826
  return;
740
827
  case 'end':
741
- cc = lines[cl].length;
742
- redraw();
828
+ moveToLineEnd();
743
829
  return;
744
830
  }
745
831
  // 可打印字符(>= 空格,非 ctrl/meta)
746
832
  const s = key.sequence ?? '';
747
833
  if (s && s >= ' ' && !key.ctrl && !key.meta) {
748
- lines[cl] = lines[cl].slice(0, cc) + s + lines[cl].slice(cc);
749
- cc += s.length;
834
+ insertText(s);
750
835
  computeFiltered();
751
836
  redraw();
752
837
  }