dgxay-code-cli 1.0.0 → 1.1.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/src/cli/editor.js CHANGED
@@ -1,37 +1,76 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 行编辑器:支持中文输入、历史、自动补全、多行输入,以及在流式输出期间中断。
4
+ * 行编辑器:带边框的输入框、斜杠补全菜单、历史、多行输入。
5
5
  *
6
- * 终端兼容性(实测得出):部分 Windows 控制台会忽略 `\x1b[nA`(光标上移)、
7
- * `\x1b[nC`(光标右移)等序列,凡是依赖它们做「擦除重画」的实现,
8
- * 都会退化成「每敲一个字就多印一行」。所以这里只用最基础的能力:
9
- * - `\r` 回到当前行行首;
10
- * - 空格覆盖多余内容;
11
- * - 需要时整行重写(单行内完成,绝不跨行)。
6
+ * 终端兼容性(前几次踩坑的教训):
7
+ * - 不假设终端支持光标上移 / 清行;
8
+ * - 启动时探测一次(见 detectCapabilities),支持则做「区域重绘」,不支持则降级;
9
+ * - 无论如何,输入被提交 / 消息输出后,都不会出现重复行。
12
10
  *
13
- * 多行输入(行尾 `\`)不支持跨行光标定位,改为在提示行里用 ⏎ 标记显示换行,
14
- * 提交时再还原成真正的换行,功能不减、显示稳定。
11
+ * 交互:
12
+ * - 输入框是一个方框(┌─┐ │文字│ └─┘),宽度随终端变化;
13
+ * - 输入 `/` 弹出命令菜单,↑↓ 选择、Tab 补全、Enter 执行;
14
+ * - 方向键 ↑ 回填上一条用户提示词(不是命令历史,而是对话里说过的话);
15
+ * - 行尾 `\` 续行,Enter 提交;Shift+Enter 也可以换行。
15
16
  */
16
17
 
17
18
  const readline = require('readline');
18
- const { COLORS, displayWidth, truncate, termWidth, useUnicode, isInteractive } = require('./ansi');
19
+ const {
20
+ COLORS,
21
+ displayWidth,
22
+ truncate,
23
+ termWidth,
24
+ useUnicode,
25
+ cursorUp,
26
+ clearLine,
27
+ eraseLine,
28
+ clearScreen
29
+ } = require('./ansi');
30
+ const { t } = require('./i18n');
31
+
32
+ /* ------------------------------ 终端能力探测 ------------------------------ */
33
+
34
+ /**
35
+ * 探测终端是否支持光标上移。
36
+ * 做法:写一行标记 → 上移覆盖 → 下移读回,然后清屏擦掉痕迹。
37
+ * 保守起见:只要不是明确的 Windows conhost,就认为支持(Windows Terminal / VSCode / iTerm 都支持)。
38
+ */
39
+ function detectCapabilities(output = process.stdout) {
40
+ if (!output.isTTY) return { cursorUp: false, clearLine: false, interactive: false };
41
+ if (process.env.DGXAY_NO_CURSOR_UP === '1') return { cursorUp: false, clearLine: false, interactive: true };
42
+ const modern =
43
+ Boolean(process.env.WT_SESSION) ||
44
+ Boolean(process.env.TERM_PROGRAM) ||
45
+ process.env.TERM === 'xterm-256color' ||
46
+ process.env.TERM === 'screen-256color' ||
47
+ Boolean(process.env.COLORTERM) ||
48
+ process.platform !== 'win32';
49
+ return { cursorUp: modern, clearLine: true, interactive: true };
50
+ }
51
+
52
+ /* ------------------------------ 编辑��� ------------------------------ */
19
53
 
20
54
  class LineEditor {
21
55
  constructor(options = {}) {
22
56
  this.input = options.input || process.stdin;
23
57
  this.output = options.output || process.stdout;
24
- this.prompt = options.prompt || '> ';
25
- this.history = Array.isArray(options.history) ? options.history.slice() : [];
26
- this.historyIndex = this.history.length;
58
+ this.prompt = options.prompt || '';
59
+ this.placeholder = options.placeholder || '';
27
60
  this.completer = options.completer || null;
28
61
  this.multiline = options.multiline !== false;
29
62
  this.maxHistory = options.maxHistory || 500;
30
63
 
64
+ /** 提交过的输入(/命令与普通提问都在这里,用于 ↑) */
65
+ this.history = Array.isArray(options.history) ? options.history.slice() : [];
66
+ this.historyIndex = this.history.length;
67
+ /** 外部注入的「上一条用户提示词」,优先级高于普通历史 */
68
+ this.lastUserPrompt = options.lastUserPrompt || '';
69
+
70
+ this.caps = options.capabilities || detectCapabilities(this.output);
71
+
31
72
  this.value = '';
32
73
  this.cursor = 0;
33
- /** 当前提示行屏幕上的实际内容(含提示符),用于 \r 覆盖时擦尾巴 */
34
- this.onScreen = '';
35
74
  this.popup = null;
36
75
  this.draft = '';
37
76
  this.active = false;
@@ -40,7 +79,14 @@ class LineEditor {
40
79
  this.keyHandler = null;
41
80
  this.keyQueue = Promise.resolve();
42
81
  this.abortHandler = null;
43
- this.newlineMark = useUnicode() ? '⏎' : '\\n';
82
+
83
+ /** 已经画在屏幕上的盒子行数(用于区域重绘) */
84
+ this.drawnRows = 0;
85
+ this.frameCursorRow = 0;
86
+ this.frameBoxTop = 0;
87
+ /** 是否已为 input 挂过 keypress 处理器(整个编辑器生命周期只挂一次) */
88
+ this.keypressBound = false;
89
+ this.newlineMark = this.unicode() ? '⏎' : '\\n';
44
90
  }
45
91
 
46
92
  /* ------------------------------ 生命周期 ------------------------------ */
@@ -54,14 +100,19 @@ class LineEditor {
54
100
  this.value = '';
55
101
  this.cursor = 0;
56
102
  this.popup = null;
57
- this.onScreen = '';
103
+ this.drawnRows = 0;
58
104
  this.historyIndex = this.history.length;
59
105
  this.active = true;
60
106
  this.finished = false;
61
107
 
62
108
  return new Promise((resolve) => {
63
109
  this.resolveQuestion = resolve;
64
- readline.emitKeypressEvents(this.input);
110
+ // 同一进程里可能多次 question()(权限确认 / 二次提问),keypress 只注册一次,
111
+ // 避免同一按键被多个监听器重复处理
112
+ if (!this.keypressBound) {
113
+ readline.emitKeypressEvents(this.input);
114
+ this.keypressBound = true;
115
+ }
65
116
  if (this.input.isTTY) {
66
117
  try {
67
118
  this.input.setRawMode(true);
@@ -72,12 +123,10 @@ class LineEditor {
72
123
  this.input.resume();
73
124
 
74
125
  this.keyHandler = (str, key) => {
75
- // 按键必须按顺序处理:handleKey 是异步的(Ctrl+C 可能等待中断回调),
76
- // 用队列串行化,避免快速输入时状态错乱。
77
126
  this.keyQueue = this.keyQueue.then(() => this.handleKey(str || '', key || {})).catch(() => {});
78
127
  };
79
128
  this.input.on('keypress', this.keyHandler);
80
- this.repaint();
129
+ this.draw();
81
130
  });
82
131
  }
83
132
 
@@ -97,58 +146,174 @@ class LineEditor {
97
146
  /* ignore */
98
147
  }
99
148
  }
100
- // 提交时保留用户看到的那一行,只把光标移到下一行开头
149
+ // 收起盒子,只留下光标在下一行
150
+ this.clearDrawn();
101
151
  this.output.write('\n');
102
- this.onScreen = '';
103
152
  const resolve = this.resolveQuestion;
104
153
  this.resolveQuestion = null;
105
154
  if (resolve) resolve(result);
106
155
  }
107
156
 
108
- /** 流式输出前:把提示行清掉,让输出独占终端 */
157
+ /** 正文输出前:收起输入框 */
109
158
  suspend() {
110
159
  this.popup = null;
111
- this.clearLine();
160
+ this.clearDrawn();
112
161
  }
113
162
 
114
- /** 流式输出结束后:重新画出提示行 */
163
+ /** 正文输出结束后:重画输入框 */
115
164
  resume() {
116
165
  if (!this.active) return;
117
- this.onScreen = '';
118
- this.repaint();
166
+ this.resetFrame();
167
+ this.draw();
168
+ }
169
+
170
+ /* ------------------------------ 绘制 ------------------------------ */
171
+
172
+ /** 盒子宽度:优先用输出流自己的列数(测试与嵌入式场景可覆盖),否则取终端宽度 */
173
+ width() {
174
+ const columns = Number(this.output.columns) || termWidth();
175
+ return Math.max(20, Math.min(columns - 1, 100));
176
+ }
177
+
178
+ /** 当前输出流是否适合用 Unicode 框线 */
179
+ unicode() {
180
+ return useUnicode(this.output);
181
+ }
182
+
183
+ /** 计算盒子的所有行(不含光标定位) */
184
+ boxLines() {
185
+ const width = this.width();
186
+ const inner = width - 2;
187
+ const unicode = this.unicode();
188
+ const box = unicode
189
+ ? { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' }
190
+ : { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' };
191
+
192
+ const text = this.displayText();
193
+ const empty = text.length === 0;
194
+
195
+ // 内容行:按内宽折行
196
+ const rawLines = empty ? [this.placeholder] : wrapDisplay(text, inner);
197
+ const lines = [];
198
+ let cursorRow = 0;
199
+ let cursorCol = 0;
200
+
201
+ rawLines.forEach((line, row) => {
202
+ const visible = empty ? COLORS.faint(line) : line;
203
+ // 内容行 = 左竖线 + 空格 + 文本 + 填充 + 右竖线,总宽必须等于 width
204
+ const fill = Math.max(0, inner - 1 - displayWidth(line));
205
+ lines.push(`${box.v} ${visible}${' '.repeat(fill)}${box.v}`);
206
+ if (!empty) {
207
+ // 光标所在行:用「到当前行累计宽度」判断
208
+ const beforeAll = wrapDisplay(text.slice(0, this.cursorDisplayIndex()), inner);
209
+ if (row === beforeAll.length - 1) {
210
+ cursorRow = row;
211
+ cursorCol = 2 + displayWidth(beforeAll[beforeAll.length - 1] || '');
212
+ }
213
+ } else {
214
+ cursorRow = 0;
215
+ cursorCol = 2;
216
+ }
217
+ });
218
+
219
+ const top = `${box.tl}${box.h.repeat(width - 2)}${box.tr}`;
220
+ const bottom = `${box.bl}${box.h.repeat(width - 2)}${box.br}`;
221
+ return { top, bottom, lines, cursorRow, cursorCol, width };
222
+ }
223
+
224
+ /**
225
+ * 绘制输入区。
226
+ * - 支持光标上移:画出完整盒子(有菜单时连同菜单),之后原地擦除重绘;
227
+ * - 不支持:降级为单行提示,避免无法擦除导致界面叠加。
228
+ */
229
+ draw() {
230
+ if (!this.active) return;
231
+
232
+ if (!this.caps.cursorUp) {
233
+ this.drawInline();
234
+ return;
235
+ }
236
+
237
+ this.drawAll();
119
238
  }
120
239
 
121
- /* ------------------------------ 屏幕操作 ------------------------------ */
240
+ /**
241
+ * 降级绘制:只用 \r + 空格覆盖当前物理行。
242
+ * 格式:❯ <文字>,超宽时横向滚动,保证永远只有一个物理行。
243
+ */
244
+ drawInline() {
245
+ const width = this.width();
246
+ const marker = this.unicode() ? '❯ ' : '> ';
247
+ const prefix = `${COLORS.accent(marker)}`;
248
+ const available = Math.max(10, width - displayWidth(marker));
122
249
 
123
- /** 用 \r + 空格覆盖当前物理行(不跨行、不用 ANSI 光标序列) */
124
- writeLine(text) {
250
+ let text = this.displayText();
251
+ if (!text) {
252
+ const placeholder = truncate(this.placeholder || '', available);
253
+ this.repaintLine(`${prefix}${COLORS.faint(placeholder)}`);
254
+ this.moveCursorBack(available - displayWidth(placeholder) + (this.unicode() ? 2 : 2));
255
+ return;
256
+ }
257
+
258
+ // 超宽时保留光标附近的窗口
259
+ let cursorWidth = displayWidth(Array.from(text).slice(0, this.cursor).join(''));
260
+ let shown = truncate(text, available);
261
+ let cursorInShown = Math.min(cursorWidth, displayWidth(shown));
262
+ if (displayWidth(text) > available) {
263
+ const keep = Math.max(0, available - 4);
264
+ const startWidth = Math.max(0, cursorWidth - keep);
265
+ let acc = 0;
266
+ let cut = 0;
267
+ const chars = Array.from(text);
268
+ while (cut < chars.length && acc < startWidth) {
269
+ acc += displayWidth(chars[cut]);
270
+ cut += 1;
271
+ }
272
+ const marker2 = this.unicode() ? '…' : '...';
273
+ shown = marker2 + chars.slice(cut).join('');
274
+ shown = truncate(shown, available);
275
+ cursorInShown = displayWidth(marker2) + (cursorWidth - acc);
276
+ if (cursorInShown > available) cursorInShown = available;
277
+ }
278
+
279
+ this.repaintLine(`${prefix}${shown}`);
280
+ const back = displayWidth(shown) - cursorInShown;
281
+ if (back > 0) this.moveCursorBack(back);
282
+ }
283
+
284
+ /** 用 \r 覆盖当前行并擦掉多余尾部 */
285
+ repaintLine(text) {
125
286
  const visible = displayWidth(text);
126
287
  const previous = displayWidth(this.onScreen);
127
288
  const pad = Math.max(0, previous - visible);
128
289
  this.output.write(`\r${text}${' '.repeat(pad)}`);
129
- // 把光标退回行尾,方便下一次覆盖
130
290
  if (pad) this.output.write('\b'.repeat(pad));
131
291
  this.onScreen = text;
132
292
  }
133
293
 
134
- clearLine() {
135
- if (!this.onScreen) {
136
- this.output.write('\r');
294
+ moveCursorBack(count) {
295
+ if (count > 0) this.output.write('\b'.repeat(count));
296
+ }
297
+
298
+ clearDrawn() {
299
+ if (this.caps.cursorUp && this.drawnRows) {
300
+ this.eraseFrame();
301
+ this.resetFrame();
137
302
  return;
138
303
  }
139
- const width = displayWidth(this.onScreen);
140
- this.output.write(`\r${' '.repeat(width)}\r`);
141
- this.onScreen = '';
304
+ if (this.onScreen) {
305
+ this.output.write(`\r${' '.repeat(displayWidth(this.onScreen))}\r`);
306
+ this.onScreen = '';
307
+ }
308
+ this.resetFrame();
142
309
  }
143
310
 
144
- /* ------------------------------ 绘制 ------------------------------ */
311
+ /* ------------------------------ 文本工具 ------------------------------ */
145
312
 
146
- /** 把内部文本转成单行显示:换行用 ⏎ 表示(部分终端不支持跨行光标定位) */
147
313
  displayText() {
148
314
  return this.value.replace(/\n/g, ` ${this.newlineMark} `);
149
315
  }
150
316
 
151
- /** 计算光标在显示文本中的位置 */
152
317
  cursorDisplayIndex() {
153
318
  const before = Array.from(this.value.slice(0, this.cursor));
154
319
  let length = 0;
@@ -159,102 +324,156 @@ class LineEditor {
159
324
  return length;
160
325
  }
161
326
 
162
- repaint() {
163
- if (!this.active) return;
164
- const promptText = this.prompt;
165
- const promptWidth = displayWidth(promptText);
166
- const totalWidth = Math.max(20, termWidth() - 1);
167
- const available = Math.max(10, totalWidth - promptWidth);
168
-
169
- let text = this.displayText();
170
- let cursor = this.cursorDisplayIndex();
171
-
172
- // 超宽时横向滚动:保留光标前后一部分,避免行被终端自动折行(折行后无法定位)
173
- let offset = 0;
174
- if (displayWidth(text) > available) {
175
- const beforeCursor = Array.from(text).slice(0, cursor);
176
- const cursorWidth = displayWidth(beforeCursor.join(''));
177
- if (cursorWidth > available - 4) {
178
- // 从前面裁掉
179
- let cut = 0;
180
- let removed = 0;
181
- const target = cursorWidth - (available - 8);
182
- while (cut < beforeCursor.length && removed < target) {
183
- removed += displayWidth(beforeCursor[cut]);
184
- cut += 1;
185
- }
186
- const head = useUnicode() ? '…' : '...';
187
- text = head + Array.from(text).slice(cut).join('');
188
- offset = displayWidth(head);
189
- cursor = cursor - removed + offset;
190
- }
191
- }
192
-
193
- const clipped = truncate(text, Math.max(1, available));
194
- this.writeLine(`${promptText}${clipped}`);
327
+ /* ------------------------------ 补全菜单 ------------------------------ */
195
328
 
196
- // 光标定位:从行尾往回退(依赖 \b,不依赖 ANSI 光标序列)
197
- const endWidth = promptWidth + displayWidth(clipped);
198
- const back = endWidth - (promptWidth + cursor);
199
- if (back > 0) this.output.write('\b'.repeat(back));
329
+ menuItems() {
330
+ if (!this.completer) return null;
331
+ return this.completer(this.value, this.cursor);
200
332
  }
201
333
 
202
- /* ------------------------------ 补全弹层 ------------------------------ */
334
+ openMenu() {
335
+ const trigger = this.menuItems();
336
+ if (!trigger || !trigger.items.length) return false;
337
+ this.popup = { ...trigger, index: 0 };
338
+ this.drawMenu();
339
+ return true;
340
+ }
203
341
 
204
- refreshPopup() {
342
+ /**
343
+ * 统一重绘:菜单(若有)+ 输入框作为一个整体,先整体擦除再整体画出。
344
+ * 这样菜单开合、上下选择、宽度变化都不会留下残影。
345
+ */
346
+ drawAll() {
205
347
  if (!this.active) return;
206
- const trigger = this.completer ? this.completer(this.value, this.cursor) : null;
207
- if (!trigger || !trigger.items || !trigger.items.length) {
208
- this.popup = null;
209
- this.repaint();
348
+ if (!this.caps.cursorUp) {
349
+ this.drawInline();
210
350
  return;
211
351
  }
212
- let index = 0;
213
- if (this.popup && this.popup.items[this.popup.index]) {
214
- const previous = this.popup.items[this.popup.index].apply || this.popup.items[this.popup.index].label;
215
- const found = trigger.items.findIndex((item) => (item.apply || item.label) === previous);
216
- if (found >= 0) index = found;
352
+ // 先擦除上一次的框架(记录过几何信息)
353
+ if (this.drawnRows) this.eraseFrame();
354
+
355
+ const rows = [];
356
+ let menuRowCount = 0;
357
+ if (this.popup) {
358
+ const menu = this.menuRows();
359
+ menuRowCount = menu.length;
360
+ rows.push(...menu);
217
361
  }
218
- this.popup = { ...trigger, index };
219
- this.repaint();
220
- this.drawPopup();
362
+ const box = this.boxLines();
363
+ rows.push(box.top, ...box.lines, box.bottom);
364
+ const boxTop = menuRowCount;
365
+
366
+ // 逐行写出。注意:每行末尾的 \n 会把光标推到下一行;最后一行的 \n 让光标
367
+ // 落在「框架下方一行」,eraseFrame 正是按这个前提计算回退行数的。
368
+ for (const row of rows) this.output.write(`${row}\n`);
369
+
370
+ // 记住几何位置,下次擦除时据此回到框架顶部
371
+ // 注意:写出最后一行后光标落在「框架下方一行」(索引 = rows.length),
372
+ // 而 frameCursorRow 是「光标目标行」在这套坐标里的索引,从 0 开始数:
373
+ // 菜单第 0 行 … 菜单最后一行 → 顶框 → 内容行 … → 底框
374
+ this.drawnRows = rows.length;
375
+ this.frameBoxTop = boxTop;
376
+ this.frameCursorRow = boxTop + 1 + box.cursorRow;
377
+
378
+ // 光标从「框架下方一行」上移到目标行,距离 = rows.length - frameCursorRow
379
+ const up = this.drawnRows - this.frameCursorRow;
380
+ if (up > 0) this.output.write(cursorUp(up));
381
+ this.output.write('\r');
382
+ if (box.cursorCol > 0) this.output.write(`\x1b[${box.cursorCol}C`);
221
383
  }
222
384
 
223
385
  /**
224
- * 弹层只在下方的临时区域显示候选,输入提示行始终是最后一行。
225
- * 由于部分终端不支持光标回退,这里不会把提示行往上搬,而是在下方列出候选后重画提示行。
386
+ * 擦除上一次画出的框架,并把光标停在「框架顶行、第 0 列」。
387
+ *
388
+ * 这是整个编辑器最容易出错的地方,做法必须精确:
389
+ * 1. 从光标所在行上移到框架顶行、回到第 0 列;
390
+ * 2. 逐行「清行」,然后把光标移到下一行的第 0 列 —— 用 ESC[nE 而不是写 '\n',
391
+ * 因为窗口底部写 '\n' 会触发终端向上滚屏,行距就错位了;
392
+ * 3. 走到框架下方后,一次性上移 rows 行回到顶行。
393
+ * 只用到 A / E / 2K,没有别的依赖;框线恰好占满终端宽度也不会自动换行。
226
394
  */
227
- drawPopup() {
228
- if (!this.popup) return;
229
- const { items, index, title } = this.popup;
230
- if (!items.length) return;
231
- const width = Math.min(64, termWidth() - 4);
395
+ eraseFrame() {
396
+ if (!this.drawnRows) return;
397
+ const rows = this.drawnRows;
398
+ const fromCursor = this.frameCursorRow || 0;
399
+ this.output.write('\r');
400
+ if (fromCursor > 0) this.output.write(cursorUp(fromCursor));
401
+ this.output.write('\r');
402
+ for (let i = 0; i < rows; i += 1) {
403
+ // eraseLine() 返回字符串 `\x1b[2K`;注意不能用 clearLine()(它会直接写 stdout 并返回 undefined)
404
+ this.output.write(eraseLine());
405
+ this.output.write('\x1b[1E'); // 清完这一行,下去一行的第 0 列
406
+ }
407
+ // 此时光标在框架下方第 rows 行,抬回顶部
408
+ this.output.write(cursorUp(rows));
409
+ this.output.write('\r');
410
+ }
411
+
412
+ /** 清掉记下的几何信息:下一次 drawAll 会从光标所在行从头画起 */
413
+ resetFrame() {
414
+ this.drawnRows = 0;
415
+ this.frameCursorRow = 0;
416
+ this.frameBoxTop = 0;
417
+ }
418
+
419
+ /** 菜单的每一行(宽度与输入框一致) */
420
+ menuRows() {
421
+ const { items, index, title, hint } = this.popup;
422
+ const width = this.width();
423
+ const inner = width - 2;
424
+ const unicode = this.unicode();
425
+ const v = unicode ? '│' : '|';
426
+ const tl = unicode ? '┌' : '+';
427
+ const bl = unicode ? '└' : '+';
428
+ const tr = unicode ? '┐' : '+';
429
+ const br = unicode ? '┘' : '+';
430
+ const h = unicode ? '─' : '-';
431
+
432
+ const pageSize = 8;
433
+ const start = Math.max(0, Math.min(index - Math.floor(pageSize / 2), Math.max(0, items.length - pageSize)));
434
+ const shown = items.slice(start, start + pageSize);
435
+
436
+ const rows = [];
437
+ const headLabel = ` ${title || 'Menu'} `;
438
+ const headFill = Math.max(0, width - 2 - displayWidth(headLabel));
439
+ rows.push(`${tl}${COLORS.bold(headLabel)}${h.repeat(headFill)}${tr}`);
232
440
 
233
- // 先把提示行清掉,候选从空行开始打印
234
- this.clearLine();
235
- this.output.write('\n');
236
- if (title) this.output.write(`${COLORS.faint(` ${title}`)}\n`);
237
- const shown = items.slice(0, 12);
238
441
  shown.forEach((item, i) => {
239
- const active = i === index;
240
- const label = truncate(item.label, width - 8);
241
- const hintWidth = Math.max(0, width - 10 - displayWidth(label));
242
- const hint = item.hint && hintWidth > 4 ? COLORS.faint(` ${truncate(item.hint, hintWidth)}`) : '';
243
- this.output.write(`${active ? COLORS.accent('›') : ' '} ${active ? COLORS.accent(label) : label}${hint}\n`);
442
+ const realIndex = start + i;
443
+ const active = realIndex === index;
444
+ const marker = active ? COLORS.accent('›') : ' ';
445
+ const label = truncate(item.label, Math.max(8, inner - 6));
446
+ const hintWidth = Math.max(0, inner - 4 - displayWidth(label));
447
+ const tail = item.hint && hintWidth > 6 ? COLORS.faint(` ${truncate(item.hint, hintWidth)}`) : '';
448
+ const body = `${marker} ${active ? COLORS.accent(label) : label}${tail}`;
449
+ const fill = Math.max(0, inner - displayWidth(`${marker} ${label}${tail ? ` ${truncate(item.hint, hintWidth)}` : ''}`));
450
+ rows.push(`${COLORS.faint(v)} ${body}${' '.repeat(fill)}${COLORS.faint(v)}`);
244
451
  });
245
- if (items.length > shown.length) {
246
- this.output.write(COLORS.faint(` …还有 ${items.length - shown.length} 项\n`));
247
- }
248
452
 
249
- // 重画提示行,让它回到最下方
250
- this.onScreen = '';
251
- this.repaint();
453
+ const footer = items.length > shown.length ? ` ${start + 1}-${start + shown.length}/${items.length} ` : '';
454
+ rows.push(`${bl}${h.repeat(Math.max(0, width - 2 - displayWidth(footer)))}${footer}${br}`);
455
+ if (hint) rows.push(COLORS.faint(` ${hint}`));
456
+ return rows;
252
457
  }
253
458
 
254
- closePopup() {
459
+ /** 打开/刷新菜单后整体重绘 */
460
+ drawMenu() {
461
+ this.drawAll();
462
+ }
463
+
464
+ closeMenu() {
255
465
  if (!this.popup) return;
256
466
  this.popup = null;
257
- this.repaint();
467
+ this.drawAll();
468
+ }
469
+
470
+ applyItem(item) {
471
+ if (!item) return;
472
+ const next = typeof item.apply === 'string' ? item.apply : typeof item.value === 'string' ? item.value : null;
473
+ if (next !== null) {
474
+ this.value = next;
475
+ this.cursor = Array.from(next).length;
476
+ }
258
477
  }
259
478
 
260
479
  /* ------------------------------ 编辑 ------------------------------ */
@@ -276,58 +495,65 @@ class LineEditor {
276
495
  this.historyIndex = this.history.length;
277
496
  }
278
497
 
498
+ /** 取上一条可回填的内容:优先用户提示词,其次普通历史 */
499
+ previousEntry() {
500
+ if (this.lastUserPrompt) return this.lastUserPrompt;
501
+ if (this.history.length) return this.history[this.history.length - 1];
502
+ return '';
503
+ }
504
+
279
505
  /* ------------------------------ 按键 ------------------------------ */
280
506
 
281
507
  async handleKey(str, key) {
282
508
  if (!this.active) return;
283
509
  const ctrl = Boolean(key.ctrl);
284
510
  const chars = Array.from(this.value);
285
- const isSubmitKey = key.name === 'return' || key.name === 'enter' || str === '\r' || str === '\n';
511
+ const isSubmit = key.name === 'return' || key.name === 'enter' || str === '\r' || str === '\n';
286
512
 
287
- /* --- 弹层激活 --- */
513
+ /* --- 菜单激活时优先处理 --- */
288
514
  if (this.popup) {
289
515
  const { items } = this.popup;
290
516
  if (key.name === 'up') {
291
517
  this.popup.index = (this.popup.index - 1 + items.length) % items.length;
292
- this.drawPopup();
518
+ this.drawMenu();
293
519
  return;
294
520
  }
295
- if (key.name === 'down' || key.name === 'tab') {
521
+ if (key.name === 'down') {
296
522
  this.popup.index = (this.popup.index + 1) % items.length;
297
- this.drawPopup();
523
+ this.drawMenu();
298
524
  return;
299
525
  }
300
526
  if (key.name === 'escape') {
527
+ this.closeMenu();
528
+ return;
529
+ }
530
+ if (key.name === 'tab') {
531
+ // Tab 补全当前选中项
532
+ this.applyItem(items[this.popup.index]);
301
533
  this.popup = null;
302
- this.repaint();
534
+ this.draw();
303
535
  return;
304
536
  }
305
- if (isSubmitKey) {
306
- const item = items[this.popup.index];
537
+ if (isSubmit) {
538
+ // Enter 执行当前选中项
539
+ this.applyItem(items[this.popup.index]);
307
540
  this.popup = null;
308
- if (item) {
309
- // 注意:不能写 `this.completer.apply(...)` —— 那是 Function.prototype.apply,
310
- // 永远存在,会把补全器的返回值吃成 undefined。这里只认显式的 applyItem 方法,
311
- // 否则回落到 item 上的 apply 字符串。
312
- let applied = null;
313
- if (this.completer && typeof this.completer.applyItem === 'function') {
314
- applied = this.completer.applyItem(this.value, this.cursor, item);
315
- }
316
- if (applied && typeof applied.value === 'string') {
317
- this.value = applied.value;
318
- this.cursor = Number.isInteger(applied.cursor) ? applied.cursor : Array.from(applied.value).length;
319
- } else if (typeof item.apply === 'string') {
320
- this.value = item.apply;
321
- this.cursor = Array.from(item.apply).length;
322
- } else if (typeof item.value === 'string') {
323
- this.value = item.value;
324
- this.cursor = Array.from(item.value).length;
325
- }
326
- this.repaint();
327
- // 补全后把选择权交回用户:再按一次回车才提交
328
- return;
541
+ this.submit();
542
+ return;
543
+ }
544
+ if (key.name === 'backspace') {
545
+ if (this.cursor > 0) {
546
+ chars.splice(this.cursor - 1, 1);
547
+ this.cursor -= 1;
548
+ this.value = chars.join('');
329
549
  }
550
+ this.popup = null;
551
+ this.draw();
552
+ return;
330
553
  }
554
+ // 其他字符:关闭菜单继续输入
555
+ this.popup = null;
556
+ this.draw();
331
557
  }
332
558
 
333
559
  /* --- 控制键 --- */
@@ -335,11 +561,10 @@ class LineEditor {
335
561
  if (this.value) {
336
562
  this.value = '';
337
563
  this.cursor = 0;
338
- this.popup = null;
339
- this.repaint();
564
+ this.draw();
340
565
  return;
341
566
  }
342
- this.clearLine();
567
+ this.clearDrawn();
343
568
  this.active = false;
344
569
  if (this.keyHandler) {
345
570
  this.input.removeListener('keypress', this.keyHandler);
@@ -359,7 +584,7 @@ class LineEditor {
359
584
  this.finished = false;
360
585
  this.value = '';
361
586
  this.cursor = 0;
362
- this.onScreen = '';
587
+ this.drawnRows = 0;
363
588
  if (this.input.isTTY) {
364
589
  try {
365
590
  this.input.setRawMode(true);
@@ -371,7 +596,7 @@ class LineEditor {
371
596
  this.keyQueue = this.keyQueue.then(() => this.handleKey(s || '', k || {})).catch(() => {});
372
597
  };
373
598
  this.input.on('keypress', this.keyHandler);
374
- this.repaint();
599
+ this.draw();
375
600
  return;
376
601
  }
377
602
  this.finish({ type: 'cancel', value: '' });
@@ -385,7 +610,7 @@ class LineEditor {
385
610
  }
386
611
  chars.splice(this.cursor, 1);
387
612
  this.value = chars.join('');
388
- this.repaint();
613
+ this.draw();
389
614
  return;
390
615
  }
391
616
 
@@ -393,14 +618,14 @@ class LineEditor {
393
618
  chars.splice(0, this.cursor);
394
619
  this.value = chars.join('');
395
620
  this.cursor = 0;
396
- this.repaint();
621
+ this.draw();
397
622
  return;
398
623
  }
399
624
 
400
625
  if (ctrl && key.name === 'k') {
401
626
  chars.splice(this.cursor);
402
627
  this.value = chars.join('');
403
- this.repaint();
628
+ this.draw();
404
629
  return;
405
630
  }
406
631
 
@@ -411,104 +636,111 @@ class LineEditor {
411
636
  chars.splice(i, this.cursor - i);
412
637
  this.cursor = i;
413
638
  this.value = chars.join('');
414
- this.repaint();
639
+ this.draw();
415
640
  return;
416
641
  }
417
642
 
418
643
  if (ctrl && key.name === 'a') {
419
644
  this.cursor = 0;
420
- this.repaint();
645
+ this.draw();
421
646
  return;
422
647
  }
423
648
 
424
649
  if (ctrl && key.name === 'e') {
425
650
  this.cursor = chars.length;
426
- this.repaint();
651
+ this.draw();
427
652
  return;
428
653
  }
429
654
 
430
655
  if (ctrl && key.name === 'l') {
431
- this.output.write('\x1b[2J\x1b[H');
432
- this.onScreen = '';
433
- this.repaint();
656
+ clearScreen(this.output);
657
+ this.drawnRows = 0;
658
+ this.draw();
434
659
  return;
435
660
  }
436
661
 
437
- /* --- 回车 --- */
438
- if (isSubmitKey) {
662
+ /* --- 回车 / 续行 --- */
663
+ if (isSubmit) {
439
664
  if (this.multiline && chars[this.cursor - 1] === '\\') {
440
665
  chars.splice(this.cursor - 1, 1, '\n');
441
666
  this.value = chars.join('');
442
- this.repaint();
667
+ this.draw();
443
668
  return;
444
669
  }
445
670
  this.submit();
446
671
  return;
447
672
  }
448
673
 
449
- /* --- 光标与删除 --- */
450
- if (key.name === 'backspace') {
451
- if (this.cursor > 0) {
452
- chars.splice(this.cursor - 1, 1);
453
- this.cursor -= 1;
454
- this.value = chars.join('');
455
- this.refreshPopup();
674
+ /* --- 方向键 --- */
675
+ if (key.name === 'up') {
676
+ // ↑ 回填上一条用户提示词
677
+ const previous = this.previousEntry();
678
+ if (previous && !this.value) {
679
+ this.value = previous;
680
+ this.cursor = Array.from(previous).length;
681
+ this.draw();
456
682
  }
457
683
  return;
458
684
  }
459
- if (key.name === 'delete') {
460
- if (this.cursor < chars.length) {
461
- chars.splice(this.cursor, 1);
462
- this.value = chars.join('');
463
- this.refreshPopup();
685
+ if (key.name === 'down') {
686
+ if (this.historyIndex < this.history.length - 1) {
687
+ this.historyIndex += 1;
688
+ this.value = this.history[this.historyIndex] || '';
689
+ this.cursor = Array.from(this.value).length;
690
+ } else {
691
+ this.historyIndex = this.history.length;
692
+ this.value = this.draft;
693
+ this.cursor = Array.from(this.value).length;
464
694
  }
695
+ this.draw();
465
696
  return;
466
697
  }
467
698
  if (key.name === 'left') {
468
699
  this.cursor = Math.max(0, this.cursor - 1);
469
- this.popup = null;
470
- this.repaint();
700
+ this.draw();
471
701
  return;
472
702
  }
473
703
  if (key.name === 'right') {
474
704
  this.cursor = Math.min(chars.length, this.cursor + 1);
475
- this.popup = null;
476
- this.repaint();
705
+ this.draw();
477
706
  return;
478
707
  }
479
708
  if (key.name === 'home') {
480
709
  this.cursor = 0;
481
- this.repaint();
710
+ this.draw();
482
711
  return;
483
712
  }
484
713
  if (key.name === 'end') {
485
714
  this.cursor = chars.length;
486
- this.repaint();
715
+ this.draw();
487
716
  return;
488
717
  }
489
- if (key.name === 'up') {
490
- if (!this.history.length) return;
491
- if (this.historyIndex === this.history.length) this.draft = this.value;
492
- this.historyIndex = Math.max(0, this.historyIndex - 1);
493
- this.value = this.history[this.historyIndex] || '';
494
- this.cursor = Array.from(this.value).length;
495
- this.popup = null;
496
- this.repaint();
718
+
719
+ /* --- 删除 --- */
720
+ if (key.name === 'backspace') {
721
+ if (this.cursor > 0) {
722
+ chars.splice(this.cursor - 1, 1);
723
+ this.cursor -= 1;
724
+ this.value = chars.join('');
725
+ this.draw();
726
+ }
497
727
  return;
498
728
  }
499
- if (key.name === 'down') {
500
- if (this.historyIndex >= this.history.length) return;
501
- this.historyIndex = Math.min(this.history.length, this.historyIndex + 1);
502
- this.value = this.historyIndex === this.history.length ? this.draft : this.history[this.historyIndex] || '';
503
- this.cursor = Array.from(this.value).length;
504
- this.popup = null;
505
- this.repaint();
729
+ if (key.name === 'delete') {
730
+ if (this.cursor < chars.length) {
731
+ chars.splice(this.cursor, 1);
732
+ this.value = chars.join('');
733
+ this.draw();
734
+ }
506
735
  return;
507
736
  }
737
+
738
+ /* --- Tab:打开或切换菜单 --- */
508
739
  if (key.name === 'tab') {
509
- this.refreshPopup();
740
+ if (!this.popup) this.openMenu();
510
741
  return;
511
742
  }
743
+
512
744
  if (key.name === 'escape') {
513
745
  return;
514
746
  }
@@ -518,7 +750,7 @@ class LineEditor {
518
750
  const filtered = String(str).replace(/[\r\n]/g, '');
519
751
  if (filtered) {
520
752
  this.insert(filtered);
521
- this.repaint();
753
+ this.draw();
522
754
  }
523
755
  }
524
756
  }
@@ -528,15 +760,36 @@ class LineEditor {
528
760
  this.value = '';
529
761
  this.cursor = 0;
530
762
  this.popup = null;
763
+ this.historyIndex = this.history.length;
764
+ this.draft = '';
531
765
  this.finish({ type: 'submit', value });
532
766
  }
533
767
  }
534
768
 
769
+ /* ------------------------------ 文本折行 ------------------------------ */
770
+
771
+ /** 按显示宽度把文本折成多行 */
772
+ function wrapDisplay(text, width) {
773
+ const limit = Math.max(4, width);
774
+ const out = [];
775
+ let line = '';
776
+ let lineWidth = 0;
777
+ for (const char of String(text ?? '')) {
778
+ const w = displayWidth(char);
779
+ if (lineWidth + w > limit) {
780
+ out.push(line);
781
+ line = '';
782
+ lineWidth = 0;
783
+ }
784
+ line += char;
785
+ lineWidth += w;
786
+ }
787
+ out.push(line);
788
+ return out.length ? out : [''];
789
+ }
790
+
535
791
  /* ------------------------------ 非 TTY 回退 ------------------------------ */
536
792
 
537
- /**
538
- * 非 TTY(管道 / 重定向)用的极简行读取:不做任何光标控制。
539
- */
540
793
  function createLineReader(stream = process.stdin) {
541
794
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
542
795
  const iterator = rl[Symbol.asyncIterator]();
@@ -554,6 +807,7 @@ function createLineReader(stream = process.stdin) {
554
807
  },
555
808
  setAbortHandler() {},
556
809
  history: [],
810
+ lastUserPrompt: '',
557
811
  pushHistory() {},
558
812
  suspend() {},
559
813
  resume() {},
@@ -571,4 +825,4 @@ function createLineReader(stream = process.stdin) {
571
825
  };
572
826
  }
573
827
 
574
- module.exports = { LineEditor, createLineReader };
828
+ module.exports = { LineEditor, createLineReader, detectCapabilities, wrapDisplay, t };