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/render.js CHANGED
@@ -3,30 +3,27 @@
3
3
  /**
4
4
  * 流式渲染:把 agent 事件流翻译成终端输出。
5
5
  *
6
- * 关键约束(来自真实 Windows 控制台的实测):不能依赖「光标上移 + 清行」来重绘。
7
- * 很多终端(尤其是某些 Windows 控制台组合)会忽略 `\x1b[nA`,
8
- * 于是每次重绘都会把整块内容当成新行往下追加,出现「一段一行」的堆积。
6
+ * 终端兼容性:只用 `\r` 回行首、空格擦尾、`\b` 退光标这些最基础能力,
7
+ * 不依赖光标上移(部分 Windows 控制台会忽略)。
9
8
  *
10
- * 因此只使用所有终端都支持的原始能力:`\r` 回到行首、空格覆盖清尾,绝不回退到上面的行。
11
- *
12
- * 渲染策略:
13
- * - 流式阶段:只在**最后一行**用 \r 实时刷新(纯文本,不解析 Markdown),
14
- * 用户看到的是一行不断变长的文字;
15
- * - 收尾阶段:抹掉这行预览,再用 Markdown 渲染器把整段完整输出一次,
16
- * 表格 / 代码块 / 标题才能正确对齐,且只发生一次。
9
+ * 三段输出:
10
+ * 1. 思考:实时显示计时 + 思考内容(超长时只保留末尾几行),结束后折叠成一行摘要;
11
+ * 2. 正文:流式期间只在最后一行刷新预览,结束时一次性按 Markdown 排版;
12
+ * 3. 工具:单行状态 + 结果摘要。
17
13
  */
18
14
 
19
15
  const { COLORS, SIGN, displayWidth, truncate, termWidth, renderMarkdown } = require('./ansi');
16
+ const { t } = require('./i18n');
20
17
 
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'
18
+ const TOOL_KEYS = {
19
+ list_directory: 'tool_list',
20
+ read_file: 'tool_read',
21
+ write_file: 'tool_write',
22
+ create_directory: 'tool_mkdir',
23
+ delete_path: 'tool_delete',
24
+ run_command: 'tool_command',
25
+ fetch_url: 'tool_fetch',
26
+ install_mcp_server: 'tool_mcp'
30
27
  };
31
28
 
32
29
  function toolSummary(name, argsRaw) {
@@ -36,14 +33,14 @@ function toolSummary(name, argsRaw) {
36
33
  } catch {
37
34
  args = {};
38
35
  }
39
- const title = TOOL_TITLES[name] || name;
36
+ const title = t(TOOL_KEYS[name] || name);
40
37
  const target = args.path || args.url || args.command || args.name || '';
41
38
  return target
42
39
  ? `${title} ${truncate(String(target).replace(/\s+/g, ' '), Math.max(20, termWidth() - 24))}`
43
40
  : title;
44
41
  }
45
42
 
46
- /** 把一行过长的文本按显示宽度折开(流式预览用,纯文本) */
43
+ /** 按显示宽度折行(纯文本预览用) */
47
44
  function foldLine(text, width) {
48
45
  const limit = Math.max(20, width);
49
46
  if (displayWidth(text) <= limit) return [text];
@@ -64,30 +61,40 @@ function foldLine(text, width) {
64
61
  return out.length ? out : [''];
65
62
  }
66
63
 
64
+ function formatSeconds(ms) {
65
+ const seconds = ms / 1000;
66
+ return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
67
+ }
68
+
67
69
  class StreamRenderer {
68
70
  constructor(options = {}) {
69
71
  this.out = options.output || process.stdout;
70
72
  this.editor = options.editor || null;
71
- // 只有真正的交互终端才做「行内实时刷新」;管道输出保持纯净
72
73
  const tty = Boolean(this.out.isTTY);
73
74
  this.live = options.redraw !== undefined ? Boolean(options.redraw) : tty;
74
75
  this.width = Math.max(20, (options.width || termWidth()) - 1);
76
+ /** 是否显示思考内容(默认 true;DGXAY_SHOW_REASONING=0 可关闭) */
77
+ this.showReasoning = process.env.DGXAY_SHOW_REASONING !== '0';
78
+ /** 思考内容最多显示几行 */
79
+ this.reasoningLines = Number(process.env.DGXAY_REASONING_LINES) || 6;
80
+ this.timer = null;
75
81
  this.reset();
76
82
  }
77
83
 
78
84
  reset() {
79
85
  this.started = false;
80
- /** 已收到的完整正文 */
81
86
  this.text = '';
82
- /** 当前物理行屏幕上的内容(用于 \r 覆盖清尾) */
83
87
  this.onScreen = '';
84
- /** 收到的换行数,用来决定收尾时是否需要重排 */
85
88
  this.newlines = 0;
86
89
  this.tools = new Map();
90
+
87
91
  this.reasoning = '';
88
92
  this.reasoningStart = 0;
89
93
  this.reasoningMs = 0;
94
+ this.reasoningRows = 0;
95
+ this.reasoningDone = false;
90
96
  this.suspended = false;
97
+ this.statusShown = false;
91
98
  }
92
99
 
93
100
  /* ------------------------------ 编辑器协调 ------------------------------ */
@@ -104,39 +111,109 @@ class StreamRenderer {
104
111
  this.editor?.resume();
105
112
  }
106
113
 
107
- /* ------------------------------ 行内刷新 ------------------------------ */
114
+ /* ------------------------------ 基础绘制 ------------------------------ */
108
115
 
109
- /**
110
- * 把「当前物理行」替换成 text:\r 回到行首,写出 text,再用空格擦掉多余的尾部。
111
- * 全程不换行、不上移,因此无论终端是否支持光标控制都不会堆积。
112
- */
113
116
  repaintLine(text) {
114
117
  const visible = displayWidth(text);
115
118
  const previous = displayWidth(this.onScreen);
116
119
  const pad = Math.max(0, previous - visible);
117
120
  this.out.write(`\r${text}${' '.repeat(pad)}`);
118
- if (pad) this.out.write(`\b`.repeat(pad));
121
+ if (pad) this.out.write('\b'.repeat(pad));
119
122
  this.onScreen = text;
120
123
  }
121
124
 
122
- /** 结束当前物理行 */
123
125
  breakLine() {
124
126
  this.out.write('\n');
125
127
  this.onScreen = '';
126
128
  }
127
129
 
128
- /* ------------------------------ 正文 ------------------------------ */
129
-
130
130
  ensureHeader() {
131
131
  if (this.started) return;
132
132
  this.started = true;
133
133
  this.suspend();
134
- this.out.write(`\n${COLORS.accent('•')} ${COLORS.bold('DgxayCode')}\n`);
134
+ this.out.write(`\n${COLORS.accent('•')} ${COLORS.bold(t('assistant'))}\n`);
135
135
  this.onScreen = '';
136
136
  }
137
137
 
138
- /** 流式阶段:交互终端才刷新预览行;管道模式下只累积,收尾时一次性输出 */
138
+ /* ------------------------------ 思考 ------------------------------ */
139
+
140
+ /** 开始思考:立刻给出提示,用户不用干等 */
141
+ beginReasoning() {
142
+ this.ensureHeader();
143
+ if (this.reasoningStart) return;
144
+ this.reasoningStart = Date.now();
145
+ this.reasoningDone = false;
146
+ this.reasoningRows = 0;
147
+ this.paintReasoning();
148
+ // 每 100ms 刷新一次计时,让用户看到“还在跑”
149
+ this.timer = setInterval(() => this.paintReasoning(), 100);
150
+ this.timer.unref?.();
151
+ }
152
+
153
+ /**
154
+ * 把已收到的思考内容画出来(末尾若干行),并带上实时计时。
155
+ * 只用 \r + 空格覆盖当前物理行,不依赖光标上移:
156
+ * - 计时和最新一行思考内容合并在同一行刷新;
157
+ * - 更早的思考内容不逐行打印,避免需要回退光标。
158
+ */
159
+ paintReasoning() {
160
+ if (!this.reasoningStart || this.reasoningDone) return;
161
+ if (!this.live) return;
162
+ const elapsed = Date.now() - this.reasoningStart;
163
+ const width = Math.max(20, this.width - 6);
164
+
165
+ if (!this.showReasoning) {
166
+ this.repaintLine(COLORS.faint(` ${SIGN.run} ${t('thinking')} ${formatSeconds(elapsed)}`));
167
+ return;
168
+ }
169
+
170
+ // 最新一行思考内容(把换行压成空格,保持单行刷新)
171
+ const latest = this.reasoning.replace(/\s+/g, ' ').trim();
172
+ const tailText = latest.length > width ? `…${latest.slice(-width)}` : latest;
173
+ const head = `${COLORS.faint(` ${SIGN.run} ${t('thinking')} ${formatSeconds(elapsed)}`)}`;
174
+ this.repaintLine(tailText ? `${head} ${COLORS.faint(tailText)}` : head);
175
+ }
176
+
177
+ /** 思考结束:当前位置就是摘要行,直接覆盖即可,不需要上移光标 */
178
+ endReasoning() {
179
+ if (!this.reasoningStart || this.reasoningDone) return;
180
+ this.reasoningDone = true;
181
+ this.reasoningMs = Date.now() - this.reasoningStart;
182
+ if (this.timer) {
183
+ clearInterval(this.timer);
184
+ this.timer = null;
185
+ }
186
+ if (this.live) {
187
+ // 覆盖掉“思考中”的实时行
188
+ this.repaintLine('');
189
+ this.out.write('\r');
190
+ this.onScreen = '';
191
+ }
192
+ if (this.reasoning.trim()) {
193
+ this.out.write(
194
+ `${COLORS.faint(` ⌁ ${t('thought_for')} ${formatSeconds(this.reasoningMs)} · ${this.reasoning.length} ${t('chars')}`)}\n`
195
+ );
196
+ // 详细内容用灰色缩进段落呈现,最多若干行,避免刷屏
197
+ if (this.showReasoning) {
198
+ const width = Math.max(20, this.width - 4);
199
+ const lines = [];
200
+ for (const line of this.reasoning.split('\n')) {
201
+ for (const piece of foldLine(line, width)) lines.push(piece);
202
+ }
203
+ const max = Math.max(1, this.reasoningLines);
204
+ const shown = lines.length > max ? lines.slice(-max) : lines;
205
+ if (lines.length > max) this.out.write(`${COLORS.faint(' │ …')}\n`);
206
+ for (const piece of shown) {
207
+ if (piece.trim()) this.out.write(`${COLORS.faint(` │ ${piece}`)}\n`);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ /* ------------------------------ 正文 ------------------------------ */
214
+
139
215
  writeText(text) {
216
+ this.endReasoning();
140
217
  this.ensureHeader();
141
218
  this.text += text;
142
219
  this.newlines += (text.match(/\n/g) || []).length;
@@ -145,20 +222,14 @@ class StreamRenderer {
145
222
  const lines = this.text.split('\n');
146
223
  const tail = lines[lines.length - 1];
147
224
  const folded = foldLine(tail, this.width);
148
- // 只保留最后一段可视宽度内的一行,避免预览超出屏幕后折行导致覆盖错位
149
- const preview = folded[folded.length - 1];
150
- this.repaintLine(preview);
225
+ this.repaintLine(folded[folded.length - 1]);
151
226
  }
152
227
 
153
- /**
154
- * 收尾:抹掉预览行,按 Markdown 完整输出正文(只发生一次)。
155
- */
156
228
  finalizeText() {
157
229
  if (!this.text) {
158
230
  this.onScreen = '';
159
231
  return;
160
232
  }
161
- // 预览行 → 清空(仅交互终端才有预览行)
162
233
  if (this.live && this.onScreen) this.repaintLine('');
163
234
  const rendered = renderMarkdown(this.text, { width: this.width });
164
235
  if (rendered && rendered.trim()) {
@@ -176,48 +247,50 @@ class StreamRenderer {
176
247
  switch (event.type) {
177
248
  case 'status':
178
249
  return;
179
- case 'delta':
180
- if (!event.text) return;
181
- this.writeText(event.text);
182
- return;
183
250
  case 'reasoning':
184
251
  if (!event.text) return;
185
- if (!this.reasoningStart) this.reasoningStart = Date.now();
186
252
  this.reasoning += event.text;
187
- this.ensureHeader();
253
+ this.beginReasoning();
254
+ if (this.showReasoning && this.live) this.paintReasoning();
255
+ return;
256
+ case 'delta':
257
+ if (!event.text) return;
258
+ this.writeText(event.text);
188
259
  return;
189
260
  case 'tool':
190
261
  this.onTool(event);
191
262
  return;
192
263
  case 'review-start':
193
264
  this.endInline();
194
- this.out.write(`${COLORS.faint(` ${SIGN.run} 正在自动审查 ${event.name}…`)}\n`);
265
+ this.out.write(`${COLORS.faint(` ${SIGN.run} ${t('reviewing')} ${event.name}…`)}\n`);
195
266
  return;
196
267
  case 'review':
197
268
  this.endInline();
198
269
  if (event.decision === 'allow') {
199
270
  this.out.write(
200
- `${COLORS.green(` ${SIGN.ok}`)} ${COLORS.green('自动审查放行')} ${COLORS.faint(
271
+ `${COLORS.green(` ${SIGN.ok}`)} ${COLORS.green(t('review_allow'))} ${COLORS.faint(
201
272
  `${event.reviewer || ''} · ${event.reason || ''}`
202
273
  )}\n`
203
274
  );
204
275
  } else {
205
276
  this.out.write(
206
- `${COLORS.yellow(` ${SIGN.run}`)} ${COLORS.yellow('自动审查判定可疑,转交确认')} ${COLORS.faint(
207
- event.reason || ''
208
- )}\n`
277
+ `${COLORS.yellow(` ${SIGN.run}`)} ${COLORS.yellow(t('review_suspect'))} ${COLORS.faint(event.reason || '')}\n`
209
278
  );
210
279
  }
211
280
  return;
212
281
  case 'waiting':
213
282
  this.endInline();
214
- this.out.write(`${COLORS.faint(` ${SIGN.run} 等待用户确认 ${event.name}…`)}\n`);
283
+ this.out.write(`${COLORS.faint(` ${SIGN.run} ${t('waiting_confirm')} ${event.name}…`)}\n`);
215
284
  return;
216
285
  case 'file':
217
286
  this.endInline();
218
287
  this.out.write(
219
288
  `${COLORS.green(` ${SIGN.ok}`)} ${COLORS.dim(
220
- event.action === 'deleted' ? '删除' : event.action === 'updated' ? '更新' : '创建'
289
+ event.action === 'deleted'
290
+ ? t('file_deleted')
291
+ : event.action === 'updated'
292
+ ? t('file_updated')
293
+ : t('file_created')
221
294
  )} ${COLORS.cyan(event.display || event.path)}${event.bytes ? COLORS.faint(` (${event.bytes} B)`) : ''}\n`
222
295
  );
223
296
  return;
@@ -231,8 +304,8 @@ class StreamRenderer {
231
304
  }
232
305
  }
233
306
 
234
- /** 正文中途插入了其他内容(工具调用、审查提示等):先把已有正文按 Markdown 落地 */
235
307
  endInline() {
308
+ this.endReasoning();
236
309
  if (this.text) this.finalizeText();
237
310
  if (this.onScreen) this.breakLine();
238
311
  }
@@ -264,61 +337,59 @@ class StreamRenderer {
264
337
  );
265
338
  }
266
339
  if (event.review?.reason) {
267
- this.out.write(
268
- `${COLORS.faint(` ${event.review.source === 'auto' ? '自动审查' : '确认'} · ${event.review.reason}`)}\n`
269
- );
340
+ const source = event.review.source === 'auto' ? t('reviewing') : t('approval_op');
341
+ this.out.write(`${COLORS.faint(` ${source} · ${event.review.reason}`)}\n`);
270
342
  }
271
343
  }
272
344
 
273
345
  toolDetail(event) {
274
346
  const output = String(event.output || '');
275
- if (event.name === 'read_file') return `${output.length} 字符`;
347
+ if (event.name === 'read_file') return `${output.length} ${t('chars')}`;
276
348
  if (event.name === 'run_command') {
277
349
  const exit = output.match(/\[exit\] code=(\S+)/);
278
- if (exit) return `退出码 ${exit[1]}`;
279
- return `${output.split('\n').filter(Boolean).length} 行输出`;
350
+ if (exit) return `${t('exit_code')} ${exit[1]}`;
351
+ return `${output.split('\n').filter(Boolean).length} ${t('lines')}`;
280
352
  }
281
- if (event.name === 'list_directory') return `${output.split('\n').filter(Boolean).length} 项`;
353
+ if (event.name === 'list_directory') return `${output.split('\n').filter(Boolean).length} ${t('items')}`;
282
354
  if (event.name === 'fetch_url') {
283
355
  const match = output.match(/^HTTP (\d{3})/);
284
356
  return match ? `HTTP ${match[1]}` : '';
285
357
  }
286
- if (event.name === 'install_mcp_server') return '已保存配置';
358
+ if (event.name === 'install_mcp_server') return 'saved';
287
359
  return '';
288
360
  }
289
361
 
290
362
  finish(event) {
291
- if (this.reasoningStart && !this.reasoningMs) this.reasoningMs = Date.now() - this.reasoningStart;
292
363
  const hadText = Boolean(this.text.trim());
364
+ this.endReasoning();
293
365
 
294
366
  if (event.type === 'error') {
295
367
  this.endInline();
296
- this.out.write(`\n${COLORS.red(`${SIGN.fail} 出错了:${event.message}`)}\n`);
368
+ this.out.write(`\n${COLORS.red(`${SIGN.fail} ${t('generation_error')}: ${event.message}`)}\n`);
297
369
  } else if (event.type === 'aborted') {
298
370
  this.finalizeText();
299
- this.out.write(`${COLORS.yellow(`${SIGN.bullet} 已停止生成`)}\n`);
371
+ this.out.write(`${COLORS.yellow(`${SIGN.bullet} ${t('stopped')}`)}\n`);
300
372
  } else {
301
373
  this.finalizeText();
302
374
  if (!hadText && !this.tools.size && !this.reasoning.trim()) {
303
- this.out.write(`${COLORS.faint('(模型没有返回内容)')}\n`);
375
+ this.out.write(`${COLORS.faint(t('no_content'))}\n`);
304
376
  }
305
377
  }
306
378
 
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
379
  if (event.usage) {
313
380
  const usage = event.usage;
314
381
  const input = usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens ?? null;
315
382
  const output = usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens ?? null;
316
383
  if (input != null || output != null) {
317
- this.out.write(`${COLORS.faint(` 用量 ${input ?? '?'} in / ${output ?? '?'} out`)}\n`);
384
+ this.out.write(`${COLORS.faint(` ${input ?? '?'} ${t('in_out')} ${output ?? '?'} ${t('out')}`)}\n`);
318
385
  }
319
386
  }
387
+ if (this.timer) {
388
+ clearInterval(this.timer);
389
+ this.timer = null;
390
+ }
320
391
  this.resume();
321
392
  }
322
393
  }
323
394
 
324
- module.exports = { StreamRenderer, toolSummary, TOOL_TITLES, foldLine };
395
+ module.exports = { StreamRenderer, toolSummary, foldLine, TOOL_KEYS, formatSeconds };