dgxay-code-cli 0.0.0-stage → 1.0.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.
Files changed (45) hide show
  1. package/LICENSE +674 -0
  2. package/README-zh.md +200 -0
  3. package/README.md +182 -2
  4. package/bin/dgxay.js +42 -0
  5. package/package.json +47 -4
  6. package/src/cli/ansi.js +509 -0
  7. package/src/cli/approval.js +186 -0
  8. package/src/cli/args.js +162 -0
  9. package/src/cli/command-table.js +52 -0
  10. package/src/cli/editor.js +574 -0
  11. package/src/cli/index.js +228 -0
  12. package/src/cli/print.js +148 -0
  13. package/src/cli/render.js +324 -0
  14. package/src/cli/repl-state.js +85 -0
  15. package/src/cli/repl.js +1167 -0
  16. package/src/cli/slash.js +74 -0
  17. package/src/cli/ui.js +52 -0
  18. package/src/commands/_shared.js +20 -0
  19. package/src/commands/chat.js +212 -0
  20. package/src/commands/config.js +363 -0
  21. package/src/commands/context.js +43 -0
  22. package/src/commands/doctor.js +192 -0
  23. package/src/commands/export.js +102 -0
  24. package/src/commands/mcp.js +257 -0
  25. package/src/commands/model.js +223 -0
  26. package/src/commands/oauth.js +246 -0
  27. package/src/commands/permission.js +108 -0
  28. package/src/commands/provider.js +332 -0
  29. package/src/commands/run.js +172 -0
  30. package/src/commands/skill.js +94 -0
  31. package/src/commands/workspace.js +100 -0
  32. package/src/core/agent.js +419 -0
  33. package/src/core/llm.js +709 -0
  34. package/src/core/mcp.js +271 -0
  35. package/src/core/oauth.js +737 -0
  36. package/src/core/prompt.js +269 -0
  37. package/src/core/sessions.js +208 -0
  38. package/src/core/tools.js +391 -0
  39. package/src/services/config.js +182 -0
  40. package/src/services/input.js +91 -0
  41. package/src/services/session-bus.js +104 -0
  42. package/src/services/settings.js +340 -0
  43. package/src/services/skills.js +125 -0
  44. package/src/services/terminal.js +124 -0
  45. package/src/services/version.js +25 -0
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CLI 调度入口:全局选项 → 子命令分发 → 无参数时进入交互模式。
5
+ */
6
+
7
+ const { COLORS } = require('./ansi');
8
+ const { parseArgs } = require('./args');
9
+ const { COMMANDS, findCommand } = require('./command-table');
10
+ const print = require('./print');
11
+ const { VERSION, BIN } = require('../services/version');
12
+
13
+ const GLOBAL_OPTIONS = [
14
+ { name: 'help', alias: 'h', type: 'boolean', desc: '显示帮助' },
15
+ { name: 'version', alias: 'v', type: 'boolean', desc: '显示版本号' },
16
+ { name: 'home', type: 'string', valueName: 'dir', desc: '覆盖数据目录(默认 ~/.dcb,也可用 DGXAY_HOME)' },
17
+ { name: 'debug', type: 'boolean', desc: '输出调试信息与堆栈' },
18
+ { name: 'no-color', type: 'boolean', desc: '关闭彩色输出(等同 NO_COLOR=1)' },
19
+ { name: 'print', alias: 'p', type: 'boolean', desc: '单次运行:发送提示词后输出结果并退出' },
20
+ { name: 'prompt', type: 'string', valueName: 'text', desc: '单次运行的提示词' },
21
+ { name: 'output', alias: 'o', type: 'string', valueName: 'format', desc: '输出格式:text / json(配合 --print)' },
22
+ { name: 'mode', alias: 'm', type: 'string', valueName: 'mode', desc: '模式:chat / work' },
23
+ { name: 'permission', type: 'string', valueName: 'level', desc: '权限:none / normal / auto / full' },
24
+ { name: 'workspace', alias: 'C', type: 'string', valueName: 'dir', desc: '工作空间目录' },
25
+ { name: 'model', type: 'string', valueName: 'id', desc: '指定模型' },
26
+ { name: 'provider', type: 'string', valueName: 'name', desc: '指定供应商' },
27
+ { name: 'skill', type: 'string', valueName: 'name', desc: '本次运行启用的技能' },
28
+ { name: 'resume', alias: 'r', type: 'string', valueName: 'id|index', desc: '恢复指定会话' },
29
+ { name: 'continue', alias: 'c', type: 'boolean', desc: '恢复最近一次会话' },
30
+ { name: 'yes', alias: 'y', type: 'boolean', desc: '自动同意所有授权请求(仅单次运行,慎用)' }
31
+ ];
32
+
33
+ const LOGO = [
34
+ ' ██████╗ ██████╗ ██╗ ██╗ █████╗ ██╗ ██╗',
35
+ ' ██╔══██╗██╔════╝ ╚██╗██╔╝██╔══██╗╚██╗ ██╔╝',
36
+ ' ██║ ██║██║ ███╗ ╚███╔╝ ███████║ ╚████╔╝ ',
37
+ ' ██║ ██║██║ ██║ ██╔██╗ ██╔══██║ ╚██╔╝ ',
38
+ ' ██████╔╝╚██████╔╝██╔╝ ██╗██║ ██║ ██║ ',
39
+ ' ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ '
40
+ ];
41
+
42
+ function showBanner() {
43
+ for (const line of LOGO) process.stdout.write(`${COLORS.accent(line)}\n`);
44
+ process.stdout.write(
45
+ `${COLORS.faint(` v${VERSION} · 多协议 AI 编码助手 · 输入 /help 查看命令 · Ctrl+C 退出`)}\n\n`
46
+ );
47
+ }
48
+
49
+ function showHelp() {
50
+ process.stdout.write(`\n${COLORS.bold(`${BIN} — DgxayCode 命令行版`)} ${COLORS.faint(`v${VERSION}`)}\n\n`);
51
+ process.stdout.write(`${COLORS.faint('用法')}\n ${BIN} [选项] [命令] [参数…]\n\n`);
52
+ process.stdout.write(`${COLORS.faint('常用命令')}\n`);
53
+ const rows = [
54
+ [`${BIN}`, '进入交互模式'],
55
+ [`${BIN} "帮我写一个 Python 脚本"`, '单次执行后退出'],
56
+ [`${BIN} run -p "..." -C ./project`, '指定工作空间单次执行'],
57
+ [`${BIN} config init`, '首次配置(填写供应商与 API Key)'],
58
+ [`${BIN} doctor`, '自检:配置、目录、模型连通性'],
59
+ [`${BIN} <命令> --help`, '查看某个命令的详细用法']
60
+ ];
61
+ const width = Math.max(...rows.map(([cmd]) => cmd.length));
62
+ for (const [cmd, desc] of rows) {
63
+ process.stdout.write(` ${COLORS.cyan(cmd.padEnd(width))} ${COLORS.faint(desc)}\n`);
64
+ }
65
+ process.stdout.write(`\n${COLORS.faint('子命令')}\n`);
66
+ const nameWidth = Math.max(...COMMANDS.map((cmd) => cmd.name.length));
67
+ for (const cmd of COMMANDS) {
68
+ const aliases = cmd.aliases?.length ? COLORS.faint(` (${cmd.aliases.join(', ')})`) : '';
69
+ process.stdout.write(` ${COLORS.accent(cmd.name.padEnd(nameWidth))} ${cmd.summary || ''}${aliases}\n`);
70
+ }
71
+ process.stdout.write(`\n${COLORS.faint('全局选项')}\n`);
72
+ const flags = GLOBAL_OPTIONS.map((opt) => {
73
+ const alias = opt.alias ? `-${opt.alias}, ` : ' ';
74
+ const value = opt.type !== 'boolean' ? ` <${opt.valueName || opt.type}>` : '';
75
+ return [`${alias}--${opt.name}${value}`, opt.desc];
76
+ });
77
+ const flagWidth = Math.max(...flags.map(([flag]) => flag.length));
78
+ for (const [flag, desc] of flags) {
79
+ process.stdout.write(` ${COLORS.cyan(flag.padEnd(flagWidth))} ${COLORS.faint(desc)}\n`);
80
+ }
81
+ process.stdout.write(
82
+ `\n${COLORS.faint('说明')} 配置与对话保存在 ${COLORS.faint('~/.dcb')}(可用 DGXAY_HOME 覆盖),可用 ${COLORS.faint(
83
+ `${BIN} config path`
84
+ )} 查看。\n\n`
85
+ );
86
+ }
87
+
88
+ /** 把全局选项转成运行参数 */
89
+ function runtimeOptions(options) {
90
+ return {
91
+ mode: options.mode,
92
+ permission: options.permission,
93
+ workspace: options.workspace,
94
+ model: options.model,
95
+ provider: options.provider,
96
+ skill: options.skill,
97
+ resume: options.resume,
98
+ continueLast: options.continue,
99
+ yes: options.yes,
100
+ output: options.output || 'text',
101
+ print: Boolean(options.print || options.prompt)
102
+ };
103
+ }
104
+
105
+ /** --home 必须在读取配置前生效:去掉该参数后重新进入 main */
106
+ function extractHome(argv) {
107
+ const args = argv.slice();
108
+ let home = null;
109
+ for (let i = 0; i < args.length; i += 1) {
110
+ const token = args[i];
111
+ if (token === '--home') {
112
+ if (args[i + 1]) {
113
+ home = args[i + 1];
114
+ args.splice(i, 2);
115
+ return { args, home };
116
+ }
117
+ args.splice(i, 1);
118
+ return { args, home };
119
+ }
120
+ if (token.startsWith('--home=')) {
121
+ home = token.slice('--home='.length);
122
+ args.splice(i, 1);
123
+ return { args, home };
124
+ }
125
+ }
126
+ return { args, home };
127
+ }
128
+
129
+ /** 清掉本项目模块的缓存,让 config.js 用新的 DGXAY_HOME 重新计算数据目录 */
130
+ function resetModuleCache() {
131
+ process.env.DGXAY_HOME = process.env.DGXAY_HOME || '';
132
+ if (!process.env.DGXAY_HOME) delete process.env.DGXAY_HOME;
133
+ const sep = require('path').sep;
134
+ const root = require('path').join(__dirname, '..');
135
+ for (const key of Object.keys(require.cache)) {
136
+ if (key.startsWith(root) && !key.includes(`${sep}bin${sep}`)) delete require.cache[key];
137
+ }
138
+ }
139
+
140
+ async function main(argv) {
141
+ let args = Array.isArray(argv) ? argv.slice() : [];
142
+
143
+ const extracted = extractHome(args);
144
+ if (extracted.home !== null) {
145
+ process.env.DGXAY_HOME = extracted.home;
146
+ args = extracted.args;
147
+ resetModuleCache();
148
+ }
149
+
150
+ let parsed;
151
+ try {
152
+ parsed = parseArgs(args, GLOBAL_OPTIONS.map((opt) => ({ ...opt })), { allowUnknown: true });
153
+ } catch (err) {
154
+ print.fail(err.message);
155
+ return 2;
156
+ }
157
+
158
+ if (parsed.options.noColor) process.env.NO_COLOR = '1';
159
+ if (parsed.options.debug) process.env.DGXAY_DEBUG = '1';
160
+
161
+ if (parsed.options.version) {
162
+ process.stdout.write(`${VERSION}\n`);
163
+ return 0;
164
+ }
165
+
166
+ const commandName = parsed.positionals[0];
167
+ const command = commandName ? findCommand(commandName) : null;
168
+
169
+ // 只有命令 token 本身放在位置参数里;它后面的全部原始参数原样交给子命令,
170
+ // 否则子命令自己的选项(如 provider add --model)会被全局解析器吞掉。
171
+ const commandIndex = commandName ? args.lastIndexOf(commandName) : -1;
172
+ const commandArgv = command ? args.slice(commandIndex + 1) : [];
173
+
174
+ if (parsed.options.help && !command) {
175
+ showHelp();
176
+ return 0;
177
+ }
178
+
179
+ const runtime = runtimeOptions(parsed.options);
180
+
181
+ if (command) {
182
+ if (parsed.options.help) {
183
+ print.usage(command);
184
+ return 0;
185
+ }
186
+ try {
187
+ const result = await command.run({
188
+ argv: commandArgv,
189
+ globalOptions: parsed.options,
190
+ runtime,
191
+ io: { command }
192
+ });
193
+ return typeof result === 'number' ? result : 0;
194
+ } catch (err) {
195
+ if (err?.userError) {
196
+ print.fail(err.message);
197
+ print.hint(`运行 ${BIN} ${command.name} --help 查看用法`);
198
+ return 2;
199
+ }
200
+ print.fail(err?.message || String(err));
201
+ if (process.env.DGXAY_DEBUG === '1' && err?.stack) process.stderr.write(`${err.stack}\n`);
202
+ return 1;
203
+ }
204
+ }
205
+
206
+ // 不是子命令:整串参数当作单次运行的提示词;否则进入交互模式
207
+ const prompt = parsed.positionals.join(' ').trim();
208
+ const repl = require('./repl');
209
+ try {
210
+ return await repl.start({
211
+ ...runtime,
212
+ initialPrompt: prompt,
213
+ // 只有显式给了提示词或 --print 才是单次运行;
214
+ // 用 -c / -r 指定会话但没有提示词时,仍是交互模式(恢复会话后继续输入)
215
+ single: Boolean(prompt) || Boolean(runtime.print)
216
+ });
217
+ } catch (err) {
218
+ if (err?.userError) {
219
+ print.fail(err.message);
220
+ return 2;
221
+ }
222
+ print.fail(err?.message || String(err));
223
+ if (process.env.DGXAY_DEBUG === '1' && err?.stack) process.stderr.write(`${err.stack}\n`);
224
+ return 1;
225
+ }
226
+ }
227
+
228
+ module.exports = { main, GLOBAL_OPTIONS, showHelp, showBanner, LOGO };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 输出辅助:统一成功 / 提示 / 错误 / 表格 / 键值列表的样式。
5
+ */
6
+
7
+ const { COLORS, SIGN, displayWidth, truncate, termWidth, renderMarkdown } = require('./ansi');
8
+
9
+ function write(text = '') {
10
+ process.stdout.write(`${text}\n`);
11
+ }
12
+
13
+ function print(text = '') {
14
+ write(text);
15
+ }
16
+
17
+ function blank() {
18
+ write('');
19
+ }
20
+
21
+ function ok(message) {
22
+ write(`${COLORS.green(SIGN.ok)} ${message}`);
23
+ }
24
+
25
+ function info(message) {
26
+ write(`${COLORS.accent(SIGN.bullet)} ${message}`);
27
+ }
28
+
29
+ function warn(message) {
30
+ write(`${COLORS.yellow('!')} ${message}`);
31
+ }
32
+
33
+ function fail(message) {
34
+ process.stderr.write(`${COLORS.red(SIGN.fail)} ${message}\n`);
35
+ }
36
+
37
+ function hint(message) {
38
+ write(COLORS.faint(message));
39
+ }
40
+
41
+ function heading(text) {
42
+ write(`${COLORS.bold(text)}`);
43
+ }
44
+
45
+ function markdown(text, width = termWidth()) {
46
+ process.stdout.write(renderMarkdown(text, { width }));
47
+ }
48
+
49
+ /** 按显示宽度补空格(中文占两格,padEnd 会算错) */
50
+ function padWidth(text, width) {
51
+ const visible = displayWidth(text);
52
+ return visible >= width ? String(text) : `${text}${' '.repeat(width - visible)}`;
53
+ }
54
+
55
+ /** 键值列表:条目 { label, value, tone } */
56
+ function keyValue(rows, options = {}) {
57
+ const indent = options.indent || ' ';
58
+ const width = Math.max(...rows.map((row) => displayWidth(String(row.label))));
59
+ for (const row of rows) {
60
+ const label = `${indent}${COLORS.faint(padWidth(String(row.label), width))}`;
61
+ const value = row.tone ? COLORS[row.tone](String(row.value ?? '')) : String(row.value ?? '');
62
+ if (row.multiline) {
63
+ write(label);
64
+ for (const line of String(row.value ?? '').split('\n')) write(`${indent}${' '.repeat(width + 2)}${line}`);
65
+ } else {
66
+ write(`${label} ${value}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ /** 表格:columns 决定列宽;rows 为二维数组 */
72
+ function table(columns, rows) {
73
+ const widths = columns.map((col, index) => {
74
+ const head = displayWidth(col.title);
75
+ const cells = rows.map((row) => displayWidth(String(row[index] ?? '')));
76
+ const max = Math.min(col.max || 60, Math.max(head, ...cells));
77
+ return Math.max(head, Math.min(max, Math.max(head, ...cells)));
78
+ });
79
+ const total = widths.reduce((sum, w) => sum + w, 0) + (columns.length - 1) * 2;
80
+ const limit = termWidth() - 1;
81
+ if (total > limit) {
82
+ const overflow = total - limit;
83
+ const widest = widths.indexOf(Math.max(...widths));
84
+ widths[widest] = Math.max(6, widths[widest] - overflow);
85
+ }
86
+ const line = (cells, painter) =>
87
+ cells
88
+ .map((cell, index) => {
89
+ const text = truncate(String(cell ?? '').replace(/\n/g, ' '), widths[index]);
90
+ const pad = ' '.repeat(Math.max(0, widths[index] - displayWidth(text)));
91
+ return painter ? painter(`${text}${pad}`) : `${text}${pad}`;
92
+ })
93
+ .join(' ')
94
+ .replace(/\s+$/, '');
95
+ write(line(columns.map((col) => col.title), COLORS.faint));
96
+ write(COLORS.faint(widths.map((w) => '─'.repeat(w)).join(' ')));
97
+ for (const row of rows) write(line(row));
98
+ }
99
+
100
+ /** 简单的使用说明 */
101
+ function usage(command) {
102
+ if (!command) return;
103
+ write(`${COLORS.bold(`dgxay ${command.name}`)}${command.summary ? COLORS.faint(` — ${command.summary}`) : ''}`);
104
+ if (command.usage) {
105
+ blank();
106
+ write(COLORS.faint('用法'));
107
+ write(` ${command.usage}`);
108
+ }
109
+ if (command.examples?.length) {
110
+ blank();
111
+ write(COLORS.faint('示例'));
112
+ for (const example of command.examples) write(` ${COLORS.cyan(example)}`);
113
+ }
114
+ if (command.options?.length) {
115
+ blank();
116
+ write(COLORS.faint('选项'));
117
+ const rows = command.options.map((opt) => {
118
+ const alias = opt.alias ? [].concat(opt.alias).map((a) => (a.length === 1 ? `-${a}` : a)).join(', ') : '';
119
+ const name = `--${opt.name}${opt.type && opt.type !== 'boolean' ? ` <${opt.valueName || opt.type}>` : ''}`;
120
+ return [alias ? `${alias}, ${name}` : name, opt.desc || ''];
121
+ });
122
+ rows.push(['-h, --help', '显示帮助']);
123
+ const width = Math.max(...rows.map(([flag]) => displayWidth(flag)));
124
+ for (const [flag, desc] of rows) write(` ${COLORS.cyan(padWidth(flag, width))} ${COLORS.faint(desc)}`);
125
+ }
126
+ if (command.subcommands?.length) {
127
+ blank();
128
+ write(COLORS.faint('子命令'));
129
+ const width = Math.max(...command.subcommands.map(([, name]) => displayWidth(name)));
130
+ for (const [name, desc] of command.subcommands) write(` ${COLORS.accent(padWidth(name, width))} ${desc}`);
131
+ }
132
+ }
133
+
134
+ module.exports = {
135
+ write,
136
+ print,
137
+ blank,
138
+ ok,
139
+ info,
140
+ warn,
141
+ fail,
142
+ hint,
143
+ heading,
144
+ keyValue,
145
+ table,
146
+ usage,
147
+ markdown
148
+ };
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 流式渲染:把 agent 事件流翻译成终端输出。
5
+ *
6
+ * 关键约束(来自真实 Windows 控制台的实测):不能依赖「光标上移 + 清行」来重绘。
7
+ * 很多终端(尤其是某些 Windows 控制台组合)会忽略 `\x1b[nA`,
8
+ * 于是每次重绘都会把整块内容当成新行往下追加,出现「一段一行」的堆积。
9
+ *
10
+ * 因此只使用所有终端都支持的原始能力:`\r` 回到行首、空格覆盖清尾,绝不回退到上面的行。
11
+ *
12
+ * 渲染策略:
13
+ * - 流式阶段:只在**最后一行**用 \r 实时刷新(纯文本,不解析 Markdown),
14
+ * 用户看到的是一行不断变长的文字;
15
+ * - 收尾阶段:抹掉这行预览,再用 Markdown 渲染器把整段完整输出一次,
16
+ * 表格 / 代码块 / 标题才能正确对齐,且只发生一次。
17
+ */
18
+
19
+ const { COLORS, SIGN, displayWidth, truncate, termWidth, renderMarkdown } = require('./ansi');
20
+
21
+ const TOOL_TITLES = {
22
+ list_directory: '列出目录',
23
+ read_file: '读取文件',
24
+ write_file: '写入文件',
25
+ create_directory: '创建目录',
26
+ delete_path: '删除',
27
+ run_command: '执行命令',
28
+ fetch_url: '访问网络',
29
+ install_mcp_server: '安装 MCP'
30
+ };
31
+
32
+ function toolSummary(name, argsRaw) {
33
+ let args = {};
34
+ try {
35
+ args = argsRaw ? JSON.parse(argsRaw) : {};
36
+ } catch {
37
+ args = {};
38
+ }
39
+ const title = TOOL_TITLES[name] || name;
40
+ const target = args.path || args.url || args.command || args.name || '';
41
+ return target
42
+ ? `${title} ${truncate(String(target).replace(/\s+/g, ' '), Math.max(20, termWidth() - 24))}`
43
+ : title;
44
+ }
45
+
46
+ /** 把一行过长的文本按显示宽度折开(流式预览用,纯文本) */
47
+ function foldLine(text, width) {
48
+ const limit = Math.max(20, width);
49
+ if (displayWidth(text) <= limit) return [text];
50
+ const out = [];
51
+ let current = '';
52
+ let currentWidth = 0;
53
+ for (const char of text) {
54
+ const w = displayWidth(char);
55
+ if (currentWidth + w > limit) {
56
+ out.push(current);
57
+ current = '';
58
+ currentWidth = 0;
59
+ }
60
+ current += char;
61
+ currentWidth += w;
62
+ }
63
+ if (current) out.push(current);
64
+ return out.length ? out : [''];
65
+ }
66
+
67
+ class StreamRenderer {
68
+ constructor(options = {}) {
69
+ this.out = options.output || process.stdout;
70
+ this.editor = options.editor || null;
71
+ // 只有真正的交互终端才做「行内实时刷新」;管道输出保持纯净
72
+ const tty = Boolean(this.out.isTTY);
73
+ this.live = options.redraw !== undefined ? Boolean(options.redraw) : tty;
74
+ this.width = Math.max(20, (options.width || termWidth()) - 1);
75
+ this.reset();
76
+ }
77
+
78
+ reset() {
79
+ this.started = false;
80
+ /** 已收到的完整正文 */
81
+ this.text = '';
82
+ /** 当前物理行屏幕上的内容(用于 \r 覆盖清尾) */
83
+ this.onScreen = '';
84
+ /** 收到的换行数,用来决定收尾时是否需要重排 */
85
+ this.newlines = 0;
86
+ this.tools = new Map();
87
+ this.reasoning = '';
88
+ this.reasoningStart = 0;
89
+ this.reasoningMs = 0;
90
+ this.suspended = false;
91
+ }
92
+
93
+ /* ------------------------------ 编辑器协调 ------------------------------ */
94
+
95
+ suspend() {
96
+ if (this.suspended) return;
97
+ this.editor?.suspend();
98
+ this.suspended = true;
99
+ }
100
+
101
+ resume() {
102
+ if (!this.suspended) return;
103
+ this.suspended = false;
104
+ this.editor?.resume();
105
+ }
106
+
107
+ /* ------------------------------ 行内刷新 ------------------------------ */
108
+
109
+ /**
110
+ * 把「当前物理行」替换成 text:\r 回到行首,写出 text,再用空格擦掉多余的尾部。
111
+ * 全程不换行、不上移,因此无论终端是否支持光标控制都不会堆积。
112
+ */
113
+ repaintLine(text) {
114
+ const visible = displayWidth(text);
115
+ const previous = displayWidth(this.onScreen);
116
+ const pad = Math.max(0, previous - visible);
117
+ this.out.write(`\r${text}${' '.repeat(pad)}`);
118
+ if (pad) this.out.write(`\b`.repeat(pad));
119
+ this.onScreen = text;
120
+ }
121
+
122
+ /** 结束当前物理行 */
123
+ breakLine() {
124
+ this.out.write('\n');
125
+ this.onScreen = '';
126
+ }
127
+
128
+ /* ------------------------------ 正文 ------------------------------ */
129
+
130
+ ensureHeader() {
131
+ if (this.started) return;
132
+ this.started = true;
133
+ this.suspend();
134
+ this.out.write(`\n${COLORS.accent('•')} ${COLORS.bold('DgxayCode')}\n`);
135
+ this.onScreen = '';
136
+ }
137
+
138
+ /** 流式阶段:交互终端才刷新预览行;管道模式下只累积,收尾时一次性输出 */
139
+ writeText(text) {
140
+ this.ensureHeader();
141
+ this.text += text;
142
+ this.newlines += (text.match(/\n/g) || []).length;
143
+ if (!this.live) return;
144
+
145
+ const lines = this.text.split('\n');
146
+ const tail = lines[lines.length - 1];
147
+ const folded = foldLine(tail, this.width);
148
+ // 只保留最后一段可视宽度内的一行,避免预览超出屏幕后折行导致覆盖错位
149
+ const preview = folded[folded.length - 1];
150
+ this.repaintLine(preview);
151
+ }
152
+
153
+ /**
154
+ * 收尾:抹掉预览行,按 Markdown 完整输出正文(只发生一次)。
155
+ */
156
+ finalizeText() {
157
+ if (!this.text) {
158
+ this.onScreen = '';
159
+ return;
160
+ }
161
+ // 预览行 → 清空(仅交互终端才有预览行)
162
+ if (this.live && this.onScreen) this.repaintLine('');
163
+ const rendered = renderMarkdown(this.text, { width: this.width });
164
+ if (rendered && rendered.trim()) {
165
+ this.out.write(rendered);
166
+ if (!rendered.endsWith('\n')) this.breakLine();
167
+ }
168
+ this.text = '';
169
+ this.newlines = 0;
170
+ this.onScreen = '';
171
+ }
172
+
173
+ /* ------------------------------ 事件分发 ------------------------------ */
174
+
175
+ handle(event) {
176
+ switch (event.type) {
177
+ case 'status':
178
+ return;
179
+ case 'delta':
180
+ if (!event.text) return;
181
+ this.writeText(event.text);
182
+ return;
183
+ case 'reasoning':
184
+ if (!event.text) return;
185
+ if (!this.reasoningStart) this.reasoningStart = Date.now();
186
+ this.reasoning += event.text;
187
+ this.ensureHeader();
188
+ return;
189
+ case 'tool':
190
+ this.onTool(event);
191
+ return;
192
+ case 'review-start':
193
+ this.endInline();
194
+ this.out.write(`${COLORS.faint(` ${SIGN.run} 正在自动审查 ${event.name}…`)}\n`);
195
+ return;
196
+ case 'review':
197
+ this.endInline();
198
+ if (event.decision === 'allow') {
199
+ this.out.write(
200
+ `${COLORS.green(` ${SIGN.ok}`)} ${COLORS.green('自动审查放行')} ${COLORS.faint(
201
+ `${event.reviewer || ''} · ${event.reason || ''}`
202
+ )}\n`
203
+ );
204
+ } else {
205
+ this.out.write(
206
+ `${COLORS.yellow(` ${SIGN.run}`)} ${COLORS.yellow('自动审查判定可疑,转交确认')} ${COLORS.faint(
207
+ event.reason || ''
208
+ )}\n`
209
+ );
210
+ }
211
+ return;
212
+ case 'waiting':
213
+ this.endInline();
214
+ this.out.write(`${COLORS.faint(` ${SIGN.run} 等待用户确认 ${event.name}…`)}\n`);
215
+ return;
216
+ case 'file':
217
+ this.endInline();
218
+ this.out.write(
219
+ `${COLORS.green(` ${SIGN.ok}`)} ${COLORS.dim(
220
+ event.action === 'deleted' ? '删除' : event.action === 'updated' ? '更新' : '创建'
221
+ )} ${COLORS.cyan(event.display || event.path)}${event.bytes ? COLORS.faint(` (${event.bytes} B)`) : ''}\n`
222
+ );
223
+ return;
224
+ case 'done':
225
+ case 'error':
226
+ case 'aborted':
227
+ this.finish(event);
228
+ return;
229
+ default:
230
+ return;
231
+ }
232
+ }
233
+
234
+ /** 正文中途插入了其他内容(工具调用、审查提示等):先把已有正文按 Markdown 落地 */
235
+ endInline() {
236
+ if (this.text) this.finalizeText();
237
+ if (this.onScreen) this.breakLine();
238
+ }
239
+
240
+ onTool(event) {
241
+ this.endInline();
242
+ const record = this.tools.get(event.id) || {};
243
+ if (event.args) record.args = event.args;
244
+ const summary = record.summary || toolSummary(event.name, event.args || record.args);
245
+ this.tools.set(event.id, { ...record, name: event.name, status: event.status, summary });
246
+
247
+ if (event.status === 'running') {
248
+ this.out.write(`${COLORS.faint(` ${SIGN.run} ${summary}…`)}\n`);
249
+ return;
250
+ }
251
+ const detail = this.toolDetail(event);
252
+ if (event.status === 'ok') {
253
+ this.out.write(
254
+ `${COLORS.green(` ${SIGN.ok}`)} ${COLORS.dim(summary)}${detail ? COLORS.faint(` · ${detail}`) : ''}\n`
255
+ );
256
+ } else {
257
+ const firstLine = String(event.output || '')
258
+ .split('\n')
259
+ .find((line) => line.trim() && !line.startsWith('$ '));
260
+ this.out.write(
261
+ `${COLORS.red(` ${SIGN.fail}`)} ${COLORS.dim(summary)}${
262
+ firstLine ? COLORS.red(` · ${truncate(firstLine, 80)}`) : ''
263
+ }\n`
264
+ );
265
+ }
266
+ if (event.review?.reason) {
267
+ this.out.write(
268
+ `${COLORS.faint(` ${event.review.source === 'auto' ? '自动审查' : '确认'} · ${event.review.reason}`)}\n`
269
+ );
270
+ }
271
+ }
272
+
273
+ toolDetail(event) {
274
+ const output = String(event.output || '');
275
+ if (event.name === 'read_file') return `${output.length} 字符`;
276
+ if (event.name === 'run_command') {
277
+ const exit = output.match(/\[exit\] code=(\S+)/);
278
+ if (exit) return `退出码 ${exit[1]}`;
279
+ return `${output.split('\n').filter(Boolean).length} 行输出`;
280
+ }
281
+ if (event.name === 'list_directory') return `${output.split('\n').filter(Boolean).length} 项`;
282
+ if (event.name === 'fetch_url') {
283
+ const match = output.match(/^HTTP (\d{3})/);
284
+ return match ? `HTTP ${match[1]}` : '';
285
+ }
286
+ if (event.name === 'install_mcp_server') return '已保存配置';
287
+ return '';
288
+ }
289
+
290
+ finish(event) {
291
+ if (this.reasoningStart && !this.reasoningMs) this.reasoningMs = Date.now() - this.reasoningStart;
292
+ const hadText = Boolean(this.text.trim());
293
+
294
+ if (event.type === 'error') {
295
+ this.endInline();
296
+ this.out.write(`\n${COLORS.red(`${SIGN.fail} 出错了:${event.message}`)}\n`);
297
+ } else if (event.type === 'aborted') {
298
+ this.finalizeText();
299
+ this.out.write(`${COLORS.yellow(`${SIGN.bullet} 已停止生成`)}\n`);
300
+ } else {
301
+ this.finalizeText();
302
+ if (!hadText && !this.tools.size && !this.reasoning.trim()) {
303
+ this.out.write(`${COLORS.faint('(模型没有返回内容)')}\n`);
304
+ }
305
+ }
306
+
307
+ if (this.reasoning.trim()) {
308
+ const ms = this.reasoningMs || 0;
309
+ const seconds = ms > 0 ? (ms / 1000).toFixed(ms < 10000 ? 1 : 0) : '0';
310
+ this.out.write(`${COLORS.faint(` ⌁ 已思考 ${seconds}s · ${this.reasoning.length} 字符`)}\n`);
311
+ }
312
+ if (event.usage) {
313
+ const usage = event.usage;
314
+ const input = usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens ?? null;
315
+ const output = usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens ?? null;
316
+ if (input != null || output != null) {
317
+ this.out.write(`${COLORS.faint(` 用量 ${input ?? '?'} in / ${output ?? '?'} out`)}\n`);
318
+ }
319
+ }
320
+ this.resume();
321
+ }
322
+ }
323
+
324
+ module.exports = { StreamRenderer, toolSummary, TOOL_TITLES, foldLine };