dgxay-code-cli 0.0.0-stage → 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/LICENSE +674 -0
- package/README-zh.md +242 -0
- package/README.md +223 -2
- package/bin/dgxay.js +42 -0
- package/package.json +47 -4
- package/src/cli/ansi.js +539 -0
- package/src/cli/approval.js +186 -0
- package/src/cli/args.js +162 -0
- package/src/cli/command-table.js +52 -0
- package/src/cli/editor.js +828 -0
- package/src/cli/i18n.js +276 -0
- package/src/cli/index.js +228 -0
- package/src/cli/print.js +148 -0
- package/src/cli/render.js +395 -0
- package/src/cli/repl-state.js +85 -0
- package/src/cli/repl.js +1231 -0
- package/src/cli/slash.js +75 -0
- package/src/cli/ui.js +52 -0
- package/src/commands/_shared.js +20 -0
- package/src/commands/chat.js +212 -0
- package/src/commands/config.js +363 -0
- package/src/commands/context.js +43 -0
- package/src/commands/doctor.js +192 -0
- package/src/commands/export.js +102 -0
- package/src/commands/mcp.js +257 -0
- package/src/commands/model.js +223 -0
- package/src/commands/oauth.js +246 -0
- package/src/commands/permission.js +108 -0
- package/src/commands/provider.js +332 -0
- package/src/commands/run.js +172 -0
- package/src/commands/skill.js +94 -0
- package/src/commands/workspace.js +100 -0
- package/src/core/agent.js +420 -0
- package/src/core/llm.js +709 -0
- package/src/core/mcp.js +271 -0
- package/src/core/oauth.js +737 -0
- package/src/core/prompt.js +269 -0
- package/src/core/sessions.js +208 -0
- package/src/core/tools.js +391 -0
- package/src/services/config.js +182 -0
- package/src/services/input.js +91 -0
- package/src/services/session-bus.js +104 -0
- package/src/services/settings.js +340 -0
- package/src/services/skills.js +125 -0
- package/src/services/terminal.js +124 -0
- package/src/services/version.js +25 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 行编辑器:带边框的输入框、斜杠补全菜单、历史、多行输入。
|
|
5
|
+
*
|
|
6
|
+
* 终端兼容性(前几次踩坑的教训):
|
|
7
|
+
* - 不假设终端支持光标上移 / 清行;
|
|
8
|
+
* - 启动时探测一次(见 detectCapabilities),支持则做「区域重绘」,不支持则降级;
|
|
9
|
+
* - 无论如何,输入被提交 / 消息输出后,都不会出现重复行。
|
|
10
|
+
*
|
|
11
|
+
* 交互:
|
|
12
|
+
* - 输入框是一个方框(┌─┐ │文字│ └─┘),宽度随终端变化;
|
|
13
|
+
* - 输入 `/` 弹出命令菜单,↑↓ 选择、Tab 补全、Enter 执行;
|
|
14
|
+
* - 方向键 ↑ 回填上一条用户提示词(不是命令历史,而是对话里说过的话);
|
|
15
|
+
* - 行尾 `\` 续行,Enter 提交;Shift+Enter 也可以换行。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const readline = require('readline');
|
|
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
|
+
/* ------------------------------ 编辑��� ------------------------------ */
|
|
53
|
+
|
|
54
|
+
class LineEditor {
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
this.input = options.input || process.stdin;
|
|
57
|
+
this.output = options.output || process.stdout;
|
|
58
|
+
this.prompt = options.prompt || '';
|
|
59
|
+
this.placeholder = options.placeholder || '';
|
|
60
|
+
this.completer = options.completer || null;
|
|
61
|
+
this.multiline = options.multiline !== false;
|
|
62
|
+
this.maxHistory = options.maxHistory || 500;
|
|
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
|
+
|
|
72
|
+
this.value = '';
|
|
73
|
+
this.cursor = 0;
|
|
74
|
+
this.popup = null;
|
|
75
|
+
this.draft = '';
|
|
76
|
+
this.active = false;
|
|
77
|
+
this.finished = false;
|
|
78
|
+
this.resolveQuestion = null;
|
|
79
|
+
this.keyHandler = null;
|
|
80
|
+
this.keyQueue = Promise.resolve();
|
|
81
|
+
this.abortHandler = null;
|
|
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';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ------------------------------ 生命周期 ------------------------------ */
|
|
93
|
+
|
|
94
|
+
setAbortHandler(handler) {
|
|
95
|
+
this.abortHandler = typeof handler === 'function' ? handler : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
question(prompt) {
|
|
99
|
+
if (prompt !== undefined) this.prompt = prompt;
|
|
100
|
+
this.value = '';
|
|
101
|
+
this.cursor = 0;
|
|
102
|
+
this.popup = null;
|
|
103
|
+
this.drawnRows = 0;
|
|
104
|
+
this.historyIndex = this.history.length;
|
|
105
|
+
this.active = true;
|
|
106
|
+
this.finished = false;
|
|
107
|
+
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
this.resolveQuestion = resolve;
|
|
110
|
+
// 同一进程里可能多次 question()(权限确认 / 二次提问),keypress 只注册一次,
|
|
111
|
+
// 避免同一按键被多个监听器重复处理
|
|
112
|
+
if (!this.keypressBound) {
|
|
113
|
+
readline.emitKeypressEvents(this.input);
|
|
114
|
+
this.keypressBound = true;
|
|
115
|
+
}
|
|
116
|
+
if (this.input.isTTY) {
|
|
117
|
+
try {
|
|
118
|
+
this.input.setRawMode(true);
|
|
119
|
+
} catch {
|
|
120
|
+
/* ignore */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.input.resume();
|
|
124
|
+
|
|
125
|
+
this.keyHandler = (str, key) => {
|
|
126
|
+
this.keyQueue = this.keyQueue.then(() => this.handleKey(str || '', key || {})).catch(() => {});
|
|
127
|
+
};
|
|
128
|
+
this.input.on('keypress', this.keyHandler);
|
|
129
|
+
this.draw();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
finish(result) {
|
|
134
|
+
if (this.finished) return;
|
|
135
|
+
this.finished = true;
|
|
136
|
+
this.active = false;
|
|
137
|
+
this.popup = null;
|
|
138
|
+
if (this.keyHandler) {
|
|
139
|
+
this.input.removeListener('keypress', this.keyHandler);
|
|
140
|
+
this.keyHandler = null;
|
|
141
|
+
}
|
|
142
|
+
if (this.input.isTTY) {
|
|
143
|
+
try {
|
|
144
|
+
this.input.setRawMode(false);
|
|
145
|
+
} catch {
|
|
146
|
+
/* ignore */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// 收起盒子,只留下光标在下一行
|
|
150
|
+
this.clearDrawn();
|
|
151
|
+
this.output.write('\n');
|
|
152
|
+
const resolve = this.resolveQuestion;
|
|
153
|
+
this.resolveQuestion = null;
|
|
154
|
+
if (resolve) resolve(result);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 正文输出前:收起输入框 */
|
|
158
|
+
suspend() {
|
|
159
|
+
this.popup = null;
|
|
160
|
+
this.clearDrawn();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 正文输出结束后:重画输入框 */
|
|
164
|
+
resume() {
|
|
165
|
+
if (!this.active) return;
|
|
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();
|
|
238
|
+
}
|
|
239
|
+
|
|
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));
|
|
249
|
+
|
|
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) {
|
|
286
|
+
const visible = displayWidth(text);
|
|
287
|
+
const previous = displayWidth(this.onScreen);
|
|
288
|
+
const pad = Math.max(0, previous - visible);
|
|
289
|
+
this.output.write(`\r${text}${' '.repeat(pad)}`);
|
|
290
|
+
if (pad) this.output.write('\b'.repeat(pad));
|
|
291
|
+
this.onScreen = text;
|
|
292
|
+
}
|
|
293
|
+
|
|
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();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (this.onScreen) {
|
|
305
|
+
this.output.write(`\r${' '.repeat(displayWidth(this.onScreen))}\r`);
|
|
306
|
+
this.onScreen = '';
|
|
307
|
+
}
|
|
308
|
+
this.resetFrame();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ------------------------------ 文本工具 ------------------------------ */
|
|
312
|
+
|
|
313
|
+
displayText() {
|
|
314
|
+
return this.value.replace(/\n/g, ` ${this.newlineMark} `);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
cursorDisplayIndex() {
|
|
318
|
+
const before = Array.from(this.value.slice(0, this.cursor));
|
|
319
|
+
let length = 0;
|
|
320
|
+
for (const char of before) {
|
|
321
|
+
if (char === '\n') length += 1 + this.newlineMark.length + 1;
|
|
322
|
+
else length += char.length;
|
|
323
|
+
}
|
|
324
|
+
return length;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ------------------------------ 补全菜单 ------------------------------ */
|
|
328
|
+
|
|
329
|
+
menuItems() {
|
|
330
|
+
if (!this.completer) return null;
|
|
331
|
+
return this.completer(this.value, this.cursor);
|
|
332
|
+
}
|
|
333
|
+
|
|
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
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 统一重绘:菜单(若有)+ 输入框作为一个整体,先整体擦除再整体画出。
|
|
344
|
+
* 这样菜单开合、上下选择、宽度变化都不会留下残影。
|
|
345
|
+
*/
|
|
346
|
+
drawAll() {
|
|
347
|
+
if (!this.active) return;
|
|
348
|
+
if (!this.caps.cursorUp) {
|
|
349
|
+
this.drawInline();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
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);
|
|
361
|
+
}
|
|
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`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 擦除上一次画出的框架,并把光标停在「框架顶行、第 0 列」。
|
|
387
|
+
*
|
|
388
|
+
* 这是整个编辑器最容易出错的地方,做法必须精确:
|
|
389
|
+
* 1. 从光标所在行上移到框架顶行、回到第 0 列;
|
|
390
|
+
* 2. 逐行「清行」,然后把光标移到下一行的第 0 列 —— 用 ESC[nE 而不是写 '\n',
|
|
391
|
+
* 因为窗口底部写 '\n' 会触发终端向上滚屏,行距就错位了;
|
|
392
|
+
* 3. 走到框架下方后,一次性上移 rows 行回到顶行。
|
|
393
|
+
* 只用到 A / E / 2K,没有别的依赖;框线恰好占满终端宽度也不会自动换行。
|
|
394
|
+
*/
|
|
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}`);
|
|
440
|
+
|
|
441
|
+
shown.forEach((item, i) => {
|
|
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)}`);
|
|
451
|
+
});
|
|
452
|
+
|
|
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;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 打开/刷新菜单后整体重绘 */
|
|
460
|
+
drawMenu() {
|
|
461
|
+
this.drawAll();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
closeMenu() {
|
|
465
|
+
if (!this.popup) return;
|
|
466
|
+
this.popup = null;
|
|
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
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ------------------------------ 编辑 ------------------------------ */
|
|
480
|
+
|
|
481
|
+
insert(text) {
|
|
482
|
+
const chars = Array.from(this.value);
|
|
483
|
+
const insertChars = Array.from(text);
|
|
484
|
+
chars.splice(this.cursor, 0, ...insertChars);
|
|
485
|
+
this.cursor += insertChars.length;
|
|
486
|
+
this.value = chars.join('');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
pushHistory(entry) {
|
|
490
|
+
const text = String(entry || '').trim();
|
|
491
|
+
if (!text) return;
|
|
492
|
+
if (this.history[this.history.length - 1] === text) return;
|
|
493
|
+
this.history.push(text);
|
|
494
|
+
if (this.history.length > this.maxHistory) this.history.splice(0, this.history.length - this.maxHistory);
|
|
495
|
+
this.historyIndex = this.history.length;
|
|
496
|
+
}
|
|
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
|
+
|
|
505
|
+
/* ------------------------------ 按键 ------------------------------ */
|
|
506
|
+
|
|
507
|
+
async handleKey(str, key) {
|
|
508
|
+
if (!this.active) return;
|
|
509
|
+
const ctrl = Boolean(key.ctrl);
|
|
510
|
+
const chars = Array.from(this.value);
|
|
511
|
+
const isSubmit = key.name === 'return' || key.name === 'enter' || str === '\r' || str === '\n';
|
|
512
|
+
|
|
513
|
+
/* --- 菜单激活时优先处理 --- */
|
|
514
|
+
if (this.popup) {
|
|
515
|
+
const { items } = this.popup;
|
|
516
|
+
if (key.name === 'up') {
|
|
517
|
+
this.popup.index = (this.popup.index - 1 + items.length) % items.length;
|
|
518
|
+
this.drawMenu();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (key.name === 'down') {
|
|
522
|
+
this.popup.index = (this.popup.index + 1) % items.length;
|
|
523
|
+
this.drawMenu();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
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]);
|
|
533
|
+
this.popup = null;
|
|
534
|
+
this.draw();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (isSubmit) {
|
|
538
|
+
// Enter 执行当前选中项
|
|
539
|
+
this.applyItem(items[this.popup.index]);
|
|
540
|
+
this.popup = null;
|
|
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('');
|
|
549
|
+
}
|
|
550
|
+
this.popup = null;
|
|
551
|
+
this.draw();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// 其他字符:关闭菜单继续输入
|
|
555
|
+
this.popup = null;
|
|
556
|
+
this.draw();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/* --- 控制键 --- */
|
|
560
|
+
if (ctrl && key.name === 'c') {
|
|
561
|
+
if (this.value) {
|
|
562
|
+
this.value = '';
|
|
563
|
+
this.cursor = 0;
|
|
564
|
+
this.draw();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
this.clearDrawn();
|
|
568
|
+
this.active = false;
|
|
569
|
+
if (this.keyHandler) {
|
|
570
|
+
this.input.removeListener('keypress', this.keyHandler);
|
|
571
|
+
this.keyHandler = null;
|
|
572
|
+
}
|
|
573
|
+
if (this.input.isTTY) {
|
|
574
|
+
try {
|
|
575
|
+
this.input.setRawMode(false);
|
|
576
|
+
} catch {
|
|
577
|
+
/* ignore */
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
this.output.write(`${COLORS.dim('^C')}\n`);
|
|
581
|
+
const handled = this.abortHandler ? await this.abortHandler() : false;
|
|
582
|
+
if (handled) {
|
|
583
|
+
this.active = true;
|
|
584
|
+
this.finished = false;
|
|
585
|
+
this.value = '';
|
|
586
|
+
this.cursor = 0;
|
|
587
|
+
this.drawnRows = 0;
|
|
588
|
+
if (this.input.isTTY) {
|
|
589
|
+
try {
|
|
590
|
+
this.input.setRawMode(true);
|
|
591
|
+
} catch {
|
|
592
|
+
/* ignore */
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
this.keyHandler = (s, k) => {
|
|
596
|
+
this.keyQueue = this.keyQueue.then(() => this.handleKey(s || '', k || {})).catch(() => {});
|
|
597
|
+
};
|
|
598
|
+
this.input.on('keypress', this.keyHandler);
|
|
599
|
+
this.draw();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.finish({ type: 'cancel', value: '' });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (ctrl && key.name === 'd') {
|
|
607
|
+
if (!this.value) {
|
|
608
|
+
this.finish({ type: 'eof', value: '' });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
chars.splice(this.cursor, 1);
|
|
612
|
+
this.value = chars.join('');
|
|
613
|
+
this.draw();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (ctrl && key.name === 'u') {
|
|
618
|
+
chars.splice(0, this.cursor);
|
|
619
|
+
this.value = chars.join('');
|
|
620
|
+
this.cursor = 0;
|
|
621
|
+
this.draw();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (ctrl && key.name === 'k') {
|
|
626
|
+
chars.splice(this.cursor);
|
|
627
|
+
this.value = chars.join('');
|
|
628
|
+
this.draw();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (ctrl && key.name === 'w') {
|
|
633
|
+
let i = this.cursor;
|
|
634
|
+
while (i > 0 && /\s/.test(chars[i - 1])) i -= 1;
|
|
635
|
+
while (i > 0 && !/\s/.test(chars[i - 1])) i -= 1;
|
|
636
|
+
chars.splice(i, this.cursor - i);
|
|
637
|
+
this.cursor = i;
|
|
638
|
+
this.value = chars.join('');
|
|
639
|
+
this.draw();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (ctrl && key.name === 'a') {
|
|
644
|
+
this.cursor = 0;
|
|
645
|
+
this.draw();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (ctrl && key.name === 'e') {
|
|
650
|
+
this.cursor = chars.length;
|
|
651
|
+
this.draw();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (ctrl && key.name === 'l') {
|
|
656
|
+
clearScreen(this.output);
|
|
657
|
+
this.drawnRows = 0;
|
|
658
|
+
this.draw();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/* --- 回车 / 续行 --- */
|
|
663
|
+
if (isSubmit) {
|
|
664
|
+
if (this.multiline && chars[this.cursor - 1] === '\\') {
|
|
665
|
+
chars.splice(this.cursor - 1, 1, '\n');
|
|
666
|
+
this.value = chars.join('');
|
|
667
|
+
this.draw();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.submit();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
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();
|
|
682
|
+
}
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
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;
|
|
694
|
+
}
|
|
695
|
+
this.draw();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (key.name === 'left') {
|
|
699
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
700
|
+
this.draw();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (key.name === 'right') {
|
|
704
|
+
this.cursor = Math.min(chars.length, this.cursor + 1);
|
|
705
|
+
this.draw();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (key.name === 'home') {
|
|
709
|
+
this.cursor = 0;
|
|
710
|
+
this.draw();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (key.name === 'end') {
|
|
714
|
+
this.cursor = chars.length;
|
|
715
|
+
this.draw();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
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
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
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
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/* --- Tab:打开或切换菜单 --- */
|
|
739
|
+
if (key.name === 'tab') {
|
|
740
|
+
if (!this.popup) this.openMenu();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (key.name === 'escape') {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/* --- 普通字符 --- */
|
|
749
|
+
if (str && !ctrl && !key.meta) {
|
|
750
|
+
const filtered = String(str).replace(/[\r\n]/g, '');
|
|
751
|
+
if (filtered) {
|
|
752
|
+
this.insert(filtered);
|
|
753
|
+
this.draw();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
submit() {
|
|
759
|
+
const value = this.value;
|
|
760
|
+
this.value = '';
|
|
761
|
+
this.cursor = 0;
|
|
762
|
+
this.popup = null;
|
|
763
|
+
this.historyIndex = this.history.length;
|
|
764
|
+
this.draft = '';
|
|
765
|
+
this.finish({ type: 'submit', value });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
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
|
+
|
|
791
|
+
/* ------------------------------ 非 TTY 回退 ------------------------------ */
|
|
792
|
+
|
|
793
|
+
function createLineReader(stream = process.stdin) {
|
|
794
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
795
|
+
const iterator = rl[Symbol.asyncIterator]();
|
|
796
|
+
let closed = false;
|
|
797
|
+
return {
|
|
798
|
+
async question() {
|
|
799
|
+
if (closed) return { type: 'eof', value: '' };
|
|
800
|
+
const { value, done } = await iterator.next();
|
|
801
|
+
if (done) {
|
|
802
|
+
closed = true;
|
|
803
|
+
rl.close();
|
|
804
|
+
return { type: 'eof', value: '' };
|
|
805
|
+
}
|
|
806
|
+
return { type: 'submit', value };
|
|
807
|
+
},
|
|
808
|
+
setAbortHandler() {},
|
|
809
|
+
history: [],
|
|
810
|
+
lastUserPrompt: '',
|
|
811
|
+
pushHistory() {},
|
|
812
|
+
suspend() {},
|
|
813
|
+
resume() {},
|
|
814
|
+
finish(result) {
|
|
815
|
+
closed = true;
|
|
816
|
+
rl.close();
|
|
817
|
+
return result;
|
|
818
|
+
},
|
|
819
|
+
get prompt() {
|
|
820
|
+
return '';
|
|
821
|
+
},
|
|
822
|
+
set prompt(value) {
|
|
823
|
+
void value;
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
module.exports = { LineEditor, createLineReader, detectCapabilities, wrapDisplay, t };
|