mingdao-harness 0.1.54

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
package/src/ui.js ADDED
@@ -0,0 +1,643 @@
1
+ // TUI 输入输出层(产品级):
2
+ // - 流式 Markdown 渲染:标题/列表/引用/行内样式
3
+ // - 代码块轻量语法高亮(零依赖,覆盖 js/ts/py/sh/json 等)
4
+ // - 工具执行可视化:写入预览、编辑 diff、bash 退出码徽章
5
+ // - 思考过程 dim 流、生成中 spinner、Ctrl+C 中断、/命令 Tab 补全、历史记录
6
+ // - 会话横幅(box)、token/耗时/费用估算状态行
7
+ // 核心引擎不依赖本模块以外的 UI 细节,未来 WebUI 只需实现同样的 io 接口。
8
+
9
+ import readline from 'node:readline';
10
+ import { MODELS } from './models.js';
11
+ import { estimateCostLabel } from './pricing.js';
12
+
13
+ export const C = {
14
+ reset: '\x1b[0m',
15
+ bold: '\x1b[1m',
16
+ dim: '\x1b[2m',
17
+ italic: '\x1b[3m',
18
+ red: '\x1b[31m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ blue: '\x1b[34m',
22
+ magenta: '\x1b[35m',
23
+ cyan: '\x1b[36m',
24
+ gray: '\x1b[90m',
25
+ };
26
+
27
+ const stdoutTTY = () => Boolean(process.stdout.isTTY);
28
+
29
+ export function style(text, code) {
30
+ if (!stdoutTTY()) {
31
+ // 非 TTY(管道/重定向):剥离所有内嵌 ANSI 码,保证输出干净可解析
32
+ return String(text).replace(/\x1b\[[0-9;]*m/g, '');
33
+ }
34
+ return `${code}${text}${C.reset}`;
35
+ }
36
+
37
+ // ---------- 终端显示宽度(CJK 占 2 列) ----------
38
+ function displayWidth(s) {
39
+ let w = 0;
40
+ for (const ch of String(s)) {
41
+ const c = ch.codePointAt(0);
42
+ const wide =
43
+ c >= 0x1100 &&
44
+ (c <= 0x115f ||
45
+ c === 0x2329 ||
46
+ c === 0x232a ||
47
+ (c >= 0x2e80 && c <= 0xa4cf && c !== 0x303f) ||
48
+ (c >= 0xac00 && c <= 0xd7a3) ||
49
+ (c >= 0xf900 && c <= 0xfaff) ||
50
+ (c >= 0xfe10 && c <= 0xfe19) ||
51
+ (c >= 0xfe30 && c <= 0xfe6f) ||
52
+ (c >= 0xff00 && c <= 0xff60) ||
53
+ (c >= 0xffe0 && c <= 0xffe6) ||
54
+ (c >= 0x20000 && c <= 0x3fffd));
55
+ w += wide ? 2 : 1;
56
+ }
57
+ return w;
58
+ }
59
+
60
+ function padTo(s, w) {
61
+ return String(s) + ' '.repeat(Math.max(0, w - displayWidth(s)));
62
+ }
63
+
64
+ function indent(s, prefix) {
65
+ return String(s)
66
+ .split('\n')
67
+ .map((l) => prefix + l)
68
+ .join('\n');
69
+ }
70
+
71
+ // ---------- 轻量代码高亮 ----------
72
+ const HASH_LANGS = new Set(['py', 'python', 'sh', 'bash', 'yaml', 'yml', 'toml', 'markdown', 'md']);
73
+ const LANG_KEYWORDS = {
74
+ js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'import', 'from', 'export', 'default', 'class', 'extends', 'new', 'await', 'async', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'null', 'undefined', 'true', 'false', 'this', 'super', 'switch', 'case', 'break', 'continue', 'do', 'of', 'in', 'static', 'get', 'set', 'delete', 'void', 'yield'],
75
+ ts: ['interface', 'type', 'enum', 'implements', 'readonly', 'private', 'public', 'protected', 'namespace', 'declare', 'as', 'any', 'unknown', 'never', 'string', 'number', 'boolean'],
76
+ py: ['def', 'return', 'if', 'elif', 'else', 'for', 'while', 'import', 'from', 'class', 'try', 'except', 'finally', 'raise', 'with', 'as', 'lambda', 'None', 'True', 'False', 'async', 'await', 'yield', 'pass', 'break', 'continue', 'global', 'nonlocal', 'assert', 'del', 'in', 'is', 'not', 'and', 'or', 'self'],
77
+ sh: ['if', 'then', 'else', 'elif', 'fi', 'for', 'do', 'done', 'while', 'echo', 'export', 'function', 'case', 'esac', 'local', 'set', 'unset', 'exit', 'return', 'source', 'read', 'shift', 'in'],
78
+ bash: ['if', 'then', 'else', 'elif', 'fi', 'for', 'do', 'done', 'while', 'echo', 'export', 'function', 'case', 'esac', 'local', 'set', 'unset', 'exit', 'return', 'source', 'read', 'shift', 'in'],
79
+ go: ['func', 'package', 'import', 'return', 'if', 'else', 'for', 'range', 'var', 'const', 'type', 'struct', 'interface', 'map', 'chan', 'go', 'defer', 'select', 'switch', 'case', 'break', 'continue', 'nil', 'true', 'false', 'error'],
80
+ rust: ['fn', 'let', 'mut', 'pub', 'use', 'mod', 'impl', 'struct', 'enum', 'trait', 'match', 'if', 'else', 'for', 'while', 'loop', 'return', 'async', 'await', 'crate', 'self', 'super', 'where', 'move', 'ref', 'Some', 'None', 'Ok', 'Err', 'true', 'false'],
81
+ java: ['public', 'private', 'protected', 'class', 'interface', 'extends', 'implements', 'static', 'final', 'void', 'int', 'long', 'String', 'new', 'return', 'if', 'else', 'for', 'while', 'try', 'catch', 'throw', 'throws', 'import', 'package', 'this', 'super', 'null', 'true', 'false'],
82
+ json: [],
83
+ yaml: [],
84
+ };
85
+
86
+ export function highlightCode(code, lang = '') {
87
+ if (!stdoutTTY()) return code;
88
+ const kws = new Set([...(LANG_KEYWORDS[lang] || []), ...(LANG_KEYWORDS[lang.replace(/-.*/, '')] || [])]);
89
+ const useHash = HASH_LANGS.has(lang);
90
+ const parts = [
91
+ '\\/\\/[^\\n]*',
92
+ '\\/\\*[\\s\\S]*?\\*\\/',
93
+ useHash ? '#[^\\n]*' : null,
94
+ '"(?:\\\\.|[^"\\\\])*"',
95
+ "'(?:\\\\.|[^'\\\\])*'",
96
+ '`(?:\\\\.|[^`\\\\])*`',
97
+ '\\b\\d[\\d._]*\\b',
98
+ '[A-Za-z_$][\\w$]*',
99
+ ].filter(Boolean);
100
+ const re = new RegExp(parts.join('|'), 'g');
101
+ let out = '';
102
+ let last = 0;
103
+ let m;
104
+ while ((m = re.exec(code))) {
105
+ out += code.slice(last, m.index);
106
+ const tok = m[0];
107
+ if (tok.startsWith('//') || tok.startsWith('/*') || tok.startsWith('#')) out += style(tok, C.gray);
108
+ else if (/^["'`]/.test(tok)) out += style(tok, C.green);
109
+ else if (/^\d/.test(tok)) out += style(tok, C.yellow);
110
+ else if (kws.has(tok)) out += style(tok, C.blue + C.bold);
111
+ else if (code[m.index + tok.length] === '(') out += style(tok, C.cyan);
112
+ else out += tok;
113
+ last = m.index + tok.length;
114
+ }
115
+ out += code.slice(last);
116
+ return out;
117
+ }
118
+
119
+ // ---------- Markdown 行内样式 ----------
120
+ function inline(s) {
121
+ const parts = [];
122
+ const re = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*)/g;
123
+ let last = 0;
124
+ let m;
125
+ while ((m = re.exec(s))) {
126
+ parts.push(s.slice(last, m.index));
127
+ const tok = m[1];
128
+ if (tok.startsWith('`')) parts.push(style(tok.slice(1, -1), C.yellow));
129
+ else if (tok.startsWith('**')) parts.push(style(tok.slice(2, -2), C.bold));
130
+ else parts.push(style(tok.slice(1, -1), C.italic));
131
+ last = m.index + tok.length;
132
+ }
133
+ parts.push(s.slice(last));
134
+ return parts.join('');
135
+ }
136
+
137
+ function renderInline(line) {
138
+ const trimmed = line.trim();
139
+ if (/^#{1,6}\s/.test(trimmed)) return style(inline(line), C.bold + C.cyan);
140
+ if (/^\s*[-*+]\s/.test(line)) return ' ' + style(inline(line), C.dim);
141
+ if (/^\s*\d+[.)]\s/.test(line)) return ' ' + style(inline(line), C.dim);
142
+ if (/^>\s?/.test(line)) return ' ' + style(inline(line), C.gray);
143
+ if (/^-{3,}$/.test(trimmed) || /^={3,}$/.test(trimmed)) return style(line, C.dim);
144
+ return inline(line);
145
+ }
146
+
147
+ // ---------- 流式渲染器 ----------
148
+ function createStreamRenderer(io) {
149
+ let buf = '';
150
+ let inCode = false;
151
+ let lang = '';
152
+ let codeLines = [];
153
+ let streamed = false;
154
+
155
+ function renderLine(line) {
156
+ const trimmed = line.trim();
157
+ if (trimmed.startsWith('```')) {
158
+ if (inCode) {
159
+ io.printCodeBlock(codeLines.join('\n'), lang);
160
+ codeLines = [];
161
+ inCode = false;
162
+ lang = '';
163
+ } else {
164
+ lang = trimmed.slice(3).trim().split(/\s+/)[0] || 'text';
165
+ inCode = true;
166
+ }
167
+ return;
168
+ }
169
+ if (inCode) {
170
+ codeLines.push(line);
171
+ return;
172
+ }
173
+ io.print(renderInline(line));
174
+ }
175
+
176
+ return {
177
+ push(text) {
178
+ streamed = true;
179
+ buf += String(text);
180
+ let nl;
181
+ while ((nl = buf.indexOf('\n')) >= 0) {
182
+ const line = buf.slice(0, nl);
183
+ buf = buf.slice(nl + 1);
184
+ renderLine(line);
185
+ }
186
+ },
187
+ end() {
188
+ if (buf) {
189
+ renderLine(buf);
190
+ buf = '';
191
+ }
192
+ if (inCode) {
193
+ io.printCodeBlock(codeLines.join('\n'), lang);
194
+ codeLines = [];
195
+ inCode = false;
196
+ }
197
+ },
198
+ get streamed() {
199
+ return streamed;
200
+ },
201
+ };
202
+ }
203
+
204
+ // ---------- 行级 diff(编辑预览用,仅小区域) ----------
205
+ function diffLines(a, b) {
206
+ const n = a.length;
207
+ const m = b.length;
208
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
209
+ for (let i = n - 1; i >= 0; i--) {
210
+ for (let j = m - 1; j >= 0; j--) {
211
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
212
+ }
213
+ }
214
+ const out = [];
215
+ let i = 0;
216
+ let j = 0;
217
+ while (i < n && j < m) {
218
+ if (a[i] === b[j]) {
219
+ out.push([' ', a[i]]);
220
+ i++;
221
+ j++;
222
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
223
+ out.push(['-', a[i]]);
224
+ i++;
225
+ } else {
226
+ out.push(['+', b[j]]);
227
+ j++;
228
+ }
229
+ }
230
+ while (i < n) out.push(['-', a[i++]]);
231
+ while (j < m) out.push(['+', b[j++]]);
232
+ return out;
233
+ }
234
+
235
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
236
+ const COMMANDS = [
237
+ '/help',
238
+ '/clear',
239
+ '/model',
240
+ '/mode',
241
+ '/compact',
242
+ '/plan',
243
+ '/init',
244
+ '/memory',
245
+ '/skills',
246
+ '/sessions',
247
+ '/usage',
248
+ '/status',
249
+ '/cost',
250
+ '/verbose',
251
+ '/save',
252
+ '/exit',
253
+ '/quit',
254
+ ];
255
+
256
+ function completeLine(line) {
257
+ if (line.startsWith('/model ') && line.length >= 7) {
258
+ const pre = line.slice(7);
259
+ return [Object.keys(MODELS).filter((m) => m.startsWith(pre)), line];
260
+ }
261
+ return [COMMANDS.filter((c) => c.startsWith(line)), line];
262
+ }
263
+
264
+ function summarizeArgs(name, args) {
265
+ try {
266
+ if (name === 'bash') return ` ${args.command ?? ''}`;
267
+ if (args.path) return ` ${args.path}`;
268
+ if (args.pattern) return ` ${args.pattern}`;
269
+ return '';
270
+ } catch {
271
+ return '';
272
+ }
273
+ }
274
+
275
+ // ---------- io 工厂 ----------
276
+ export function createIO({ quiet = false } = {}) {
277
+ let rl = null;
278
+ let spinnerTimer = null;
279
+ let renderer = null;
280
+ let askQueue = []; // 非 TTY:挂起的输入请求
281
+ let lineQueue = []; // 非 TTY:已缓冲但未被消费的行
282
+ let sigintHandler = null; // TTY 下 rl 'SIGINT' 事件转发目标
283
+ let ttyPending = null;
284
+ let closed = false;
285
+
286
+ const EOF_ERROR = new Error('输入流已结束(stdin 关闭),无法继续交互。请使用 TTY 运行,或使用单次提问模式:mingdao "问题"。');
287
+
288
+ const io = {
289
+ _reasoningStarted: false,
290
+ _answerStarted: false,
291
+ isTTY: Boolean(process.stdin.isTTY),
292
+ showReasoning: true,
293
+
294
+ setShowReasoning(v) {
295
+ io.showReasoning = !!v;
296
+ },
297
+
298
+ ensureRl() {
299
+ if (rl && rl.closed) throw EOF_ERROR;
300
+ if (!rl) {
301
+ const terminal = Boolean(process.stdin.isTTY);
302
+ rl = readline.createInterface({
303
+ input: process.stdin,
304
+ output: process.stdout,
305
+ terminal,
306
+ historySize: 200,
307
+ completer: (line) => completeLine(line),
308
+ });
309
+ if (terminal) {
310
+ // 关键:TTY 下 Ctrl+C 由 readline 捕获(raw 模式),需要在这里转发给中断处理器;
311
+ // 注册该监听会抑制 readline 默认的 close 行为,无处理器时显式 close
312
+ rl.on('SIGINT', () => {
313
+ if (sigintHandler) {
314
+ try {
315
+ sigintHandler();
316
+ } catch {}
317
+ } else {
318
+ rl.close();
319
+ }
320
+ });
321
+ }
322
+ if (!terminal) {
323
+ // 非 TTY(管道输入):持久 line 监听 + 队列,避免 rl.question 丢弃首行之后的输入
324
+ rl.on('line', (line) => {
325
+ const trimmed = line.trim();
326
+ const pending = askQueue.shift();
327
+ if (pending) pending.resolve(trimmed);
328
+ else lineQueue.push(trimmed);
329
+ });
330
+ }
331
+ rl.on('close', () => {
332
+ closed = true;
333
+ while (askQueue.length) {
334
+ const p = askQueue.shift();
335
+ p.reject(EOF_ERROR);
336
+ }
337
+ if (ttyPending) {
338
+ ttyPending.restore?.();
339
+ ttyPending.resolve('');
340
+ ttyPending = null;
341
+ }
342
+ });
343
+ }
344
+ return rl;
345
+ },
346
+
347
+ setHistory(lines) {
348
+ try {
349
+ const r = io.ensureRl();
350
+ for (const l of lines.slice(-50)) {
351
+ if (l && !r.history.includes(l)) r.history.push(l);
352
+ }
353
+ } catch {}
354
+ },
355
+
356
+ print(text = '') {
357
+ if (!quiet) console.log(text);
358
+ },
359
+
360
+ box(title, lines) {
361
+ if (quiet) return;
362
+ if (!stdoutTTY()) {
363
+ io.print(title);
364
+ for (const l of lines) io.print(l);
365
+ return;
366
+ }
367
+ const inner = Math.max(...lines.map(displayWidth), displayWidth(title)) + 4;
368
+ const topTitle = ` ${title} `;
369
+ const rest = inner - displayWidth(topTitle);
370
+ io.print(style('╭─', C.cyan) + topTitle + style('─'.repeat(rest) + '╮', C.cyan));
371
+ for (const l of lines) {
372
+ io.print(style('│ ', C.cyan) + padTo(l, inner) + style(' │', C.cyan));
373
+ }
374
+ io.print(style('╰', C.cyan) + style('─'.repeat(inner) + '╯', C.cyan));
375
+ },
376
+
377
+ // —— 一轮生成的生命周期 ——
378
+ beginTurn() {
379
+ io._reasoningStarted = false;
380
+ io._answerStarted = false;
381
+ renderer = createStreamRenderer(io);
382
+ },
383
+
384
+ endTurn() {
385
+ if (renderer) {
386
+ renderer.end();
387
+ renderer = null;
388
+ }
389
+ io.stopSpinner();
390
+ },
391
+
392
+ startSpinner(label) {
393
+ if (quiet || !stdoutTTY()) return;
394
+ io.stopSpinner();
395
+ let i = 0;
396
+ process.stdout.write(style(SPINNER_FRAMES[0] + ` ${label} `, C.dim));
397
+ spinnerTimer = setInterval(() => {
398
+ i = (i + 1) % SPINNER_FRAMES.length;
399
+ process.stdout.write('\r' + style(SPINNER_FRAMES[i] + ` ${label} `, C.dim));
400
+ }, 80);
401
+ },
402
+
403
+ stopSpinner() {
404
+ if (spinnerTimer) {
405
+ clearInterval(spinnerTimer);
406
+ spinnerTimer = null;
407
+ process.stdout.write('\r\x1b[K');
408
+ }
409
+ },
410
+
411
+ writeText(text) {
412
+ if (quiet) return;
413
+ if (io._reasoningStarted && !io._answerStarted && stdoutTTY()) {
414
+ io._answerStarted = true;
415
+ io.print(style('─'.repeat(20) + ' 回答 ' + '─'.repeat(20), C.dim));
416
+ }
417
+ renderer?.push(String(text));
418
+ },
419
+
420
+ writeReasoning(text) {
421
+ if (quiet || !io.showReasoning) return;
422
+ if (!io._reasoningStarted) {
423
+ io._reasoningStarted = true;
424
+ io.print(style('┄ 思考过程', C.dim));
425
+ }
426
+ process.stdout.write(style(String(text), C.dim));
427
+ },
428
+
429
+ renderTodo(todos) {
430
+ if (quiet || !todos?.length) return;
431
+ io.print(style(' ☑ 任务清单', C.bold));
432
+ for (const t of todos) {
433
+ const mark = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '◐' : '○';
434
+ const color = t.status === 'completed' ? C.dim : t.status === 'in_progress' ? C.cyan : C.gray;
435
+ io.print(style(` ${mark} ${t.content}`, color));
436
+ }
437
+ },
438
+
439
+ printCodeBlock(code, lang) {
440
+ if (quiet) return;
441
+ const label = lang || 'code';
442
+ const prefix = stdoutTTY() ? ' │ ' : '';
443
+ if (stdoutTTY()) io.print(style(`┌─ ${label} `, C.gray));
444
+ io.print(prefix + highlightCode(code, lang));
445
+ if (stdoutTTY()) io.print(style('└─', C.gray));
446
+ },
447
+
448
+ // —— 工具执行可视化 ——
449
+ renderTool(name, args, result, durationMs) {
450
+ if (quiet) return;
451
+ const r = result && typeof result === 'object' ? result : { ok: false, error: String(result ?? '') };
452
+ const ms = durationMs != null ? ` · ${durationMs}ms` : '';
453
+
454
+ if (name === 'write' && r.ok) {
455
+ io.print(style(` ✎ 写入 ${args.path}`, C.bold));
456
+ const lines = String(args.content ?? '').split('\n');
457
+ const preview = lines.slice(0, 8).join('\n');
458
+ if (preview) io.print(style(indent(preview, ' │ '), C.dim));
459
+ if (lines.length > 8) io.print(style(` │ …(共 ${lines.length} 行)`, C.dim));
460
+ io.print(style(` ✓ ${r.output}${ms}`, C.green));
461
+ return;
462
+ }
463
+
464
+ if (name === 'edit' && r.ok) {
465
+ io.print(style(` ✎ 编辑 ${args.path}`, C.bold));
466
+ if (r.diff) {
467
+ const before = r.diff.before.split('\n');
468
+ const after = r.diff.after.split('\n');
469
+ if (before.length + after.length <= 60) {
470
+ for (const [kind, line] of diffLines(before, after)) {
471
+ const color = kind === '-' ? C.red : kind === '+' ? C.green : C.dim;
472
+ io.print(style(` ${kind} ${line}`, color));
473
+ }
474
+ } else {
475
+ io.print(style(' …(变更区域较大,省略 diff 预览)', C.dim));
476
+ }
477
+ }
478
+ io.print(style(` ✓ ${r.output}${ms}`, C.green));
479
+ return;
480
+ }
481
+
482
+ if (name === 'bash' && r.ok) {
483
+ io.print(style(` ⚙ ${args.command ?? ''}`, C.bold));
484
+ const badge = r.timedOut ? '⏱ 超时' : `exit ${r.exitCode}`;
485
+ io.print(style(` ${badge}${ms}`, r.exitCode === 0 ? C.green : C.red));
486
+ const out = String(r.stdout ?? '').trim();
487
+ if (out) {
488
+ const lines = out.split('\n');
489
+ io.print(style(indent(lines.slice(0, 10).join('\n'), ' '), C.dim));
490
+ if (lines.length > 10) io.print(style(` …(共 ${lines.length} 行输出)`, C.dim));
491
+ }
492
+ const errT = String(r.stderr ?? '').trim();
493
+ if (errT) io.print(style(indent(errT.split('\n').slice(-4).join('\n'), ' '), C.red));
494
+ return;
495
+ }
496
+
497
+ if (!r.ok) {
498
+ io.print(style(` ✖ ${name}${summarizeArgs(name, args)}:${r.error}${ms}`, C.red));
499
+ return;
500
+ }
501
+
502
+ if (name === 'ls') {
503
+ // 目录列表只给一行摘要 + 前几项,不整屏刷文件
504
+ const lines = String(r.output ?? '').split('\n').filter(Boolean);
505
+ io.print(style(` 📁 列出 ${lines.length} 项` + (lines.length ? ':' + lines.slice(0, 3).map((x) => x.trim()).join('、') + (lines.length > 3 ? ' 等' : '') : '') + ms, C.bold));
506
+ return;
507
+ }
508
+
509
+ if (name === 'skill') {
510
+ // 技能全文只供模型使用,终端只给一行摘要
511
+ const out = String(r.output ?? '');
512
+ const desc = (out.match(/^description:\s*(.+)$/m) || [])[1] || '';
513
+ io.print(style(` 🧩 已加载技能 ${args.name}${desc ? ':' + desc.slice(0, 60) : ''}(${out.length} 字)${ms}`, C.bold));
514
+ return;
515
+ }
516
+
517
+ // 只读工具:read / ls / glob / grep
518
+ io.print(style(` 🔍 ${name}${summarizeArgs(name, args)}`, C.bold));
519
+ const out = String(r.output ?? '');
520
+ if (out) {
521
+ const lines = out.split('\n');
522
+ io.print(style(indent(lines.slice(0, 8).join('\n'), ' '), C.dim));
523
+ if (lines.length > 8) io.print(style(` …(共 ${lines.length} 行)`, C.dim));
524
+ }
525
+ io.print(style(` ✓ 输出 ${out.length} 字符${ms}`, C.green));
526
+ },
527
+
528
+ renderToolDenied(name, args, reason) {
529
+ if (quiet) return;
530
+ const label = reason === '未授权' || !reason ? '未授权,已跳过' : reason;
531
+ io.print(style(` ✖ ${label}:${name}${summarizeArgs(name, args)}`, C.yellow));
532
+ },
533
+
534
+ // —— 用量与费用状态行 ——
535
+ printUsageLine({ modelName, usage, durationMs }) {
536
+ if (quiet) return;
537
+ const p = usage?.prompt_tokens ?? 0;
538
+ const c = usage?.completion_tokens ?? 0;
539
+ const fmt = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
540
+ const secs = ((durationMs ?? 0) / 1000).toFixed(1) + 's';
541
+ const costPart = estimateCostLabel(modelName, p, c, usage);
542
+ io.print(
543
+ style(
544
+ `[${C.bold}${modelName}${C.reset} · ${fmt(p)}↑/${fmt(c)}↓ tokens · ${secs}${costPart}]`,
545
+ C.dim
546
+ )
547
+ );
548
+ },
549
+
550
+ // —— SIGINT(Ctrl+C 中断生成,不退出程序) ——
551
+ // TTY 下 readline raw 模式会把 Ctrl+C 转成 rl 的 'SIGINT' 事件(ensureRl 转发到 sigintHandler),
552
+ // 非 TTY/其他来源走 process 级 SIGINT;两层都注册,保证任何路径都能中断
553
+ onSigint(fn) {
554
+ const h = () => {
555
+ try {
556
+ fn();
557
+ } catch {}
558
+ };
559
+ sigintHandler = h;
560
+ process.on('SIGINT', h);
561
+ return () => {
562
+ sigintHandler = null;
563
+ process.removeListener('SIGINT', h);
564
+ };
565
+ },
566
+
567
+ ask(question, opts = {}) {
568
+ if (!process.stdin.isTTY) {
569
+ // 非 TTY:优先消费已缓冲的行(接口关闭后仍可能有剩余行),不打印提示符
570
+ if (lineQueue.length) return Promise.resolve(lineQueue.shift());
571
+ if (closed) return Promise.reject(EOF_ERROR);
572
+ let r;
573
+ try {
574
+ r = io.ensureRl();
575
+ } catch (e) {
576
+ return Promise.reject(e); // 确保 ask 永远以 rejection 报告,不向调用方同步抛错
577
+ }
578
+ if (r.closed) return Promise.reject(EOF_ERROR);
579
+ return new Promise((resolve, reject) => {
580
+ askQueue.push({ resolve, reject });
581
+ });
582
+ }
583
+ const r = io.ensureRl();
584
+ // TTY:rl.question 保留行编辑 / 补全 / 历史 / 隐藏输入
585
+ return new Promise((resolve) => {
586
+ const orig = r._writeToOutput;
587
+ const restore = () => {
588
+ if (typeof orig === 'function') r._writeToOutput = orig;
589
+ };
590
+ if (opts.hidden && typeof r._writeToOutput === 'function') {
591
+ r._writeToOutput = () => {};
592
+ }
593
+ const entry = { resolve, restore };
594
+ ttyPending = entry;
595
+ r.question(question, (answer) => {
596
+ restore();
597
+ if (ttyPending === entry) ttyPending = null;
598
+ resolve(answer.trim());
599
+ });
600
+ });
601
+ },
602
+
603
+ // 行尾反斜杠续行,支持多行输入
604
+ async askMultiline(prompt, continuation = '…> ') {
605
+ const lines = [];
606
+ for (;;) {
607
+ const line = await io.ask(prompt);
608
+ if (line.endsWith('\\')) {
609
+ lines.push(line.slice(0, -1));
610
+ prompt = continuation;
611
+ continue;
612
+ }
613
+ lines.push(line);
614
+ return lines.join('\n');
615
+ }
616
+ },
617
+
618
+ async confirm(question) {
619
+ const a = await io.ask(question + ' ');
620
+ return /^y(es)?$/i.test(a);
621
+ },
622
+
623
+ async choose(label, options) {
624
+ io.print(style(label, C.bold));
625
+ options.forEach((o, i) => io.print(` ${style(String(i + 1), C.cyan)}. ${o.label}`));
626
+ for (;;) {
627
+ const ans = await io.ask('请输入序号:');
628
+ const n = Number(ans);
629
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1].value;
630
+ io.print(style('输入无效,请重新输入。', C.yellow));
631
+ }
632
+ },
633
+
634
+ close() {
635
+ if (rl) {
636
+ rl.close();
637
+ rl = null;
638
+ }
639
+ },
640
+ };
641
+
642
+ return io;
643
+ }