dave-code 1.0.4 → 1.2.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/README.md +39 -5
- package/bin/aiClient.js +878 -159
- package/bin/check.js +11 -0
- package/bin/cliMenu.js +251 -172
- package/bin/commandRouter.js +70 -0
- package/bin/configManager.js +153 -64
- package/bin/contextManager.js +167 -0
- package/bin/index.js +2103 -573
- package/bin/markdownRenderer.js +264 -0
- package/bin/memoryManager.js +182 -0
- package/bin/planManager.js +291 -0
- package/bin/projectNotebookManager.js +839 -0
- package/bin/runtimeEvents.js +104 -0
- package/bin/scanManager.js +561 -0
- package/bin/sessionManager.js +182 -0
- package/bin/terminalRenderer.js +701 -0
- package/bin/textWidth.js +194 -0
- package/bin/thunderManager.js +302 -0
- package/bin/thunderOrchestrator.js +263 -0
- package/bin/thunderPrompts.js +53 -0
- package/bin/thunderRenderer.js +200 -0
- package/bin/toolRuntime.js +1607 -0
- package/package.json +6 -5
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { renderTerminalMarkdown } from './markdownRenderer.js';
|
|
2
|
+
import {
|
|
3
|
+
charWidth,
|
|
4
|
+
displayWidth,
|
|
5
|
+
padCenter,
|
|
6
|
+
padEnd,
|
|
7
|
+
sanitizeUntrustedText,
|
|
8
|
+
stripAnsi,
|
|
9
|
+
truncateEnd,
|
|
10
|
+
truncateMiddle,
|
|
11
|
+
wrapText
|
|
12
|
+
} from './textWidth.js';
|
|
13
|
+
|
|
14
|
+
export { displayWidth, sanitizeUntrustedText, stripAnsi, truncateMiddle };
|
|
15
|
+
|
|
16
|
+
const COLORS = {
|
|
17
|
+
brand: '\x1b[1;38;2;250;100;30m',
|
|
18
|
+
active: '\x1b[36m',
|
|
19
|
+
userBackground: '\x1b[48;5;236m',
|
|
20
|
+
success: '\x1b[32m',
|
|
21
|
+
warning: '\x1b[33m',
|
|
22
|
+
error: '\x1b[31m',
|
|
23
|
+
muted: '\x1b[90m',
|
|
24
|
+
accent: '\x1b[38;2;250;100;30m',
|
|
25
|
+
reset: '\x1b[0m'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PHASE_ORDER = ['scan', 'read', 'act', 'verify'];
|
|
29
|
+
const PHASE_TITLES = {
|
|
30
|
+
cn: { scan: '扫描', read: '读取', act: '执行', verify: '验证' },
|
|
31
|
+
en: { scan: 'SCAN', read: 'READ', act: 'ACT', verify: 'VERIFY' }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format a conversation using a shape-based role hierarchy. The markers stay
|
|
36
|
+
* distinct without colour and continuation lines keep a hanging indent so a
|
|
37
|
+
* multi-line message reads as one block.
|
|
38
|
+
*/
|
|
39
|
+
export function formatConversationMessage(message, { color = true, width = 0 } = {}) {
|
|
40
|
+
const role = message?.role === 'user' ? 'user' : 'assistant';
|
|
41
|
+
const content = sanitizeUntrustedText(message?.displayContent || message?.content || '');
|
|
42
|
+
if (!content) return '';
|
|
43
|
+
|
|
44
|
+
const marker = role === 'user' ? '>' : '●';
|
|
45
|
+
const tone = role === 'user' ? COLORS.active : COLORS.brand;
|
|
46
|
+
const lines = content.split('\n');
|
|
47
|
+
if (role === 'assistant') {
|
|
48
|
+
const rendered = renderTerminalMarkdown(content, { color, width: width > 0 ? Math.max(20, width - 2) : 80 });
|
|
49
|
+
const renderedLines = rendered.split('\n');
|
|
50
|
+
const prefix = color ? `${tone}${marker}${COLORS.reset}` : marker;
|
|
51
|
+
return `${prefix} ${renderedLines[0]}${renderedLines.slice(1).map(line => `\n ${line}`).join('')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const blockWidth = Number.isFinite(width) && width > 0
|
|
55
|
+
? Math.max(4, Math.floor(width))
|
|
56
|
+
: Math.max(...lines.map(line => displayWidth(line) + 2));
|
|
57
|
+
const contentWidth = Math.max(2, blockWidth - 2);
|
|
58
|
+
const logicalLines = [];
|
|
59
|
+
let firstLine = true;
|
|
60
|
+
for (const sourceLine of lines) {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let chunk = '';
|
|
63
|
+
for (const char of sourceLine) {
|
|
64
|
+
if (chunk && displayWidth(chunk + char) > contentWidth) {
|
|
65
|
+
chunks.push(chunk);
|
|
66
|
+
chunk = char;
|
|
67
|
+
} else {
|
|
68
|
+
chunk += char;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
for (const value of chunks) {
|
|
73
|
+
logicalLines.push(`${firstLine ? '> ' : ' '}${value}`);
|
|
74
|
+
firstLine = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!color) return logicalLines.join('\n');
|
|
79
|
+
return logicalLines.map((line, index) => {
|
|
80
|
+
const padding = ' '.repeat(Math.max(0, blockWidth - displayWidth(line)));
|
|
81
|
+
const coloredLine = index === 0
|
|
82
|
+
? `${COLORS.active}>\x1b[39m${line.slice(1)}`
|
|
83
|
+
: `\x1b[39m${line}`;
|
|
84
|
+
return `${COLORS.userBackground}${coloredLine}${padding}${COLORS.reset}`;
|
|
85
|
+
}).join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatDuration(ms) {
|
|
89
|
+
if (!Number.isFinite(ms) || ms < 0) return '';
|
|
90
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
91
|
+
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatTokens(value) {
|
|
95
|
+
const count = Number(value) || 0;
|
|
96
|
+
if (count < 1000) return String(count);
|
|
97
|
+
return `${(count / 1000).toFixed(count < 10000 ? 1 : 0)}k`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Short label for a tool, so parallel rows align in a narrow column. */
|
|
101
|
+
function toolTag(tool = '') {
|
|
102
|
+
const name = String(tool).toUpperCase();
|
|
103
|
+
const tags = {
|
|
104
|
+
READ_FILE: 'READ',
|
|
105
|
+
INSPECT_FILE: 'OUTLINE',
|
|
106
|
+
LIST_DIR: 'LIST',
|
|
107
|
+
SEARCH_GREP: 'SEARCH',
|
|
108
|
+
GLOB_FILES: 'GLOB',
|
|
109
|
+
READ_NOTEBOOK: 'NOTE',
|
|
110
|
+
EDIT_FILE: 'EDIT',
|
|
111
|
+
WRITE_FILE: 'WRITE',
|
|
112
|
+
MAKE_DIR: 'MKDIR',
|
|
113
|
+
MOVE_PATH: 'MOVE',
|
|
114
|
+
DELETE_PATH: 'DELETE',
|
|
115
|
+
RUN_COMMAND: 'RUN',
|
|
116
|
+
READ_TASK_OUTPUT: 'TASK',
|
|
117
|
+
KILL_TASK: 'KILL',
|
|
118
|
+
UPDATE_TODOS: 'TODO'
|
|
119
|
+
};
|
|
120
|
+
return tags[name] || name.slice(0, 7) || 'TOOL';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createTerminalRenderer({
|
|
124
|
+
stdout = process.stdout,
|
|
125
|
+
lang = 'cn',
|
|
126
|
+
color = true,
|
|
127
|
+
maxCompleted = 8,
|
|
128
|
+
now = Date.now
|
|
129
|
+
} = {}) {
|
|
130
|
+
const isTTY = !!stdout.isTTY;
|
|
131
|
+
const useColor = Boolean(color && isTTY && !process.env.NO_COLOR);
|
|
132
|
+
const useUnicode = Boolean(isTTY && process.env.TERM !== 'dumb');
|
|
133
|
+
const frames = useUnicode
|
|
134
|
+
? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
135
|
+
: ['-', '\\', '|', '/'];
|
|
136
|
+
const symbols = useUnicode
|
|
137
|
+
? { done: '✓', failed: '×', warning: '!', more: '…', pending: '○', running: '●', branch: '├', last: '└', bar: '│' }
|
|
138
|
+
: { done: '+', failed: 'x', warning: '!', more: '...', pending: 'o', running: '*', branch: '|', last: '\\', bar: '|' };
|
|
139
|
+
const cn = lang === 'cn';
|
|
140
|
+
const labels = cn
|
|
141
|
+
? {
|
|
142
|
+
analyzing: '思考中',
|
|
143
|
+
organizing: '正在整理结果',
|
|
144
|
+
retrying: '流式响应不可用,正在切换兼容模式',
|
|
145
|
+
compacted: '已压缩上下文',
|
|
146
|
+
cancelled: '操作已取消',
|
|
147
|
+
failed: '执行失败',
|
|
148
|
+
extra: '另有步骤已完成',
|
|
149
|
+
parallel: '并行执行',
|
|
150
|
+
tools: '个工具',
|
|
151
|
+
todos: '任务清单',
|
|
152
|
+
cache: '缓存'
|
|
153
|
+
}
|
|
154
|
+
: {
|
|
155
|
+
analyzing: 'Thinking',
|
|
156
|
+
organizing: 'Organizing results',
|
|
157
|
+
retrying: 'Streaming unavailable, switching to compatibility mode',
|
|
158
|
+
compacted: 'Context compacted',
|
|
159
|
+
cancelled: 'Operation cancelled',
|
|
160
|
+
failed: 'Turn failed',
|
|
161
|
+
extra: 'additional steps completed',
|
|
162
|
+
parallel: 'Running',
|
|
163
|
+
tools: 'tools in parallel',
|
|
164
|
+
todos: 'Task checklist',
|
|
165
|
+
cache: 'cache'
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let frameIndex = 0;
|
|
169
|
+
let timer = null;
|
|
170
|
+
let paused = false;
|
|
171
|
+
let disposed = false;
|
|
172
|
+
let answerOpen = false;
|
|
173
|
+
let answerEndsWithNewline = true;
|
|
174
|
+
let completedCount = 0;
|
|
175
|
+
let hiddenCount = 0;
|
|
176
|
+
let renderedRows = 0;
|
|
177
|
+
let lastPainted = '';
|
|
178
|
+
|
|
179
|
+
// Narrative slot: the single "what is Dave thinking" line.
|
|
180
|
+
let status = null;
|
|
181
|
+
// Parallel slots keyed by toolCallId, so concurrent tools each keep a row.
|
|
182
|
+
const slots = new Map();
|
|
183
|
+
// Phase track state and the live context/cache gauge.
|
|
184
|
+
let phase = null;
|
|
185
|
+
const phaseDone = new Set();
|
|
186
|
+
let gauge = null;
|
|
187
|
+
let todos = [];
|
|
188
|
+
|
|
189
|
+
const paint = (name, text) => useColor ? `${COLORS[name]}${text}${COLORS.reset}` : text;
|
|
190
|
+
const write = text => stdout.write(String(text));
|
|
191
|
+
|
|
192
|
+
function terminalWidth() {
|
|
193
|
+
return Math.max(24, stdout.columns || 80);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function phaseTrackLine() {
|
|
197
|
+
if (!phase && phaseDone.size === 0) return '';
|
|
198
|
+
const titles = PHASE_TITLES[cn ? 'cn' : 'en'];
|
|
199
|
+
const width = terminalWidth();
|
|
200
|
+
const segments = PHASE_ORDER.map(name => {
|
|
201
|
+
const title = titles[name];
|
|
202
|
+
if (phaseDone.has(name) && phase !== name) return paint('success', `${symbols.done} ${title}`);
|
|
203
|
+
if (phase === name) return paint('active', `${symbols.running} ${title}`);
|
|
204
|
+
return paint('muted', `${symbols.pending} ${title}`);
|
|
205
|
+
});
|
|
206
|
+
const connector = paint('muted', useUnicode ? ' ── ' : ' -- ');
|
|
207
|
+
// Two leading spaces keep the track from reading as another "✓ log" row.
|
|
208
|
+
const track = ` ${segments.join(connector)}`;
|
|
209
|
+
const gaugeText = gauge ? paint('muted', gauge) : '';
|
|
210
|
+
if (!gaugeText) return track;
|
|
211
|
+
const spacing = width - displayWidth(track) - displayWidth(gaugeText);
|
|
212
|
+
if (spacing < 2) return track;
|
|
213
|
+
return `${track}${' '.repeat(spacing)}${gaugeText}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function slotLine(slot, { isLast = true, tree = true } = {}) {
|
|
217
|
+
const mark = slot.status === 'done'
|
|
218
|
+
? paint('success', symbols.done)
|
|
219
|
+
: slot.status === 'failed'
|
|
220
|
+
? paint('error', symbols.failed)
|
|
221
|
+
: paint('active', frames[frameIndex]);
|
|
222
|
+
const tag = padEnd(toolTag(slot.tool), 7);
|
|
223
|
+
const elapsed = slot.status === 'running' ? formatDuration(now() - slot.startedAt) : slot.duration;
|
|
224
|
+
const suffix = elapsed ? paint('muted', ` · ${elapsed}`) : '';
|
|
225
|
+
// Tree rows sit one level deeper than the "running N tools" header; a lone
|
|
226
|
+
// tool is the primary line and shares the header's indentation.
|
|
227
|
+
const branch = tree ? ` ${paint('muted', isLast ? symbols.last : symbols.branch)} ` : '';
|
|
228
|
+
const head = ` ${branch}${mark} ${paint('muted', tag)} `;
|
|
229
|
+
const available = Math.max(8, terminalWidth() - displayWidth(head) - displayWidth(suffix));
|
|
230
|
+
return `${head}${truncateEnd(slot.label, available)}${suffix}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function todoLines() {
|
|
234
|
+
if (todos.length === 0) return [];
|
|
235
|
+
const done = todos.filter(todo => todo.status === 'completed').length;
|
|
236
|
+
const lines = [` ${paint('accent', labels.todos)} ${paint('muted', `${done}/${todos.length}`)}`];
|
|
237
|
+
for (const todo of todos) {
|
|
238
|
+
const mark = todo.status === 'completed'
|
|
239
|
+
? paint('success', symbols.done)
|
|
240
|
+
: todo.status === 'in_progress'
|
|
241
|
+
? paint('active', useUnicode ? '▶' : '>')
|
|
242
|
+
: paint('muted', symbols.pending);
|
|
243
|
+
const text = sanitizeUntrustedText(todo.content);
|
|
244
|
+
const body = todo.status === 'completed' && useColor ? `\x1b[9m${paint('muted', text)}\x1b[29m` : text;
|
|
245
|
+
lines.push(` ${mark} ${truncateEnd(body, Math.max(10, terminalWidth() - 6))}`);
|
|
246
|
+
}
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The live region is a fixed block redrawn in place: phase track, narrative
|
|
252
|
+
* status, one row per in-flight tool, and the task checklist. Completed log
|
|
253
|
+
* lines scroll above it untouched, so history and scrollback stay intact.
|
|
254
|
+
*/
|
|
255
|
+
function liveRegionLines() {
|
|
256
|
+
const lines = [];
|
|
257
|
+
const track = phaseTrackLine();
|
|
258
|
+
if (track) lines.push(track);
|
|
259
|
+
|
|
260
|
+
const running = [...slots.values()]
|
|
261
|
+
.filter(slot => slot.status === 'running')
|
|
262
|
+
.sort((a, b) => a.seq - b.seq);
|
|
263
|
+
|
|
264
|
+
if (status) {
|
|
265
|
+
const elapsed = terminalWidth() >= 58 && status.startedAt ? formatDuration(now() - status.startedAt) : '';
|
|
266
|
+
const spinner = paint('active', frames[frameIndex]);
|
|
267
|
+
const suffix = elapsed ? paint('muted', ` · ${elapsed}`) : '';
|
|
268
|
+
const head = ` ${spinner} `;
|
|
269
|
+
const available = Math.max(10, terminalWidth() - displayWidth(head) - displayWidth(suffix));
|
|
270
|
+
lines.push(`${head}${paint('active', truncateEnd(status.label, available))}${suffix}`);
|
|
271
|
+
running.forEach((slot, index) => lines.push(slotLine(slot, { isLast: index === running.length - 1 })));
|
|
272
|
+
} else if (running.length === 1) {
|
|
273
|
+
// A single tool needs no tree: it reads better as the primary line.
|
|
274
|
+
lines.push(slotLine(running[0], { tree: false }));
|
|
275
|
+
} else if (running.length > 1) {
|
|
276
|
+
const spinner = paint('active', frames[frameIndex]);
|
|
277
|
+
lines.push(` ${spinner} ${paint('active', `${labels.parallel} ${running.length} ${labels.tools}`)}`);
|
|
278
|
+
running.forEach((slot, index) => lines.push(slotLine(slot, { isLast: index === running.length - 1 })));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
lines.push(...todoLines());
|
|
282
|
+
return lines;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Cursor sequence that moves back to the first row of the live block. */
|
|
286
|
+
function cursorToBlockStart() {
|
|
287
|
+
let sequence = '\r';
|
|
288
|
+
for (let row = 1; row < renderedRows; row++) sequence += '\x1b[1A';
|
|
289
|
+
return sequence;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clearLive() {
|
|
293
|
+
if (!isTTY || renderedRows === 0) return;
|
|
294
|
+
write(`${cursorToBlockStart()}\x1b[J`);
|
|
295
|
+
renderedRows = 0;
|
|
296
|
+
lastPainted = '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Repaint the block in a single write. Erasing and rewriting as two separate
|
|
301
|
+
* writes let the terminal present the blank intermediate state, which read as
|
|
302
|
+
* a flicker on every 90ms spinner frame. Each row is overwritten in place with
|
|
303
|
+
* an inline erase instead, and only genuinely surplus rows are cleared.
|
|
304
|
+
*/
|
|
305
|
+
function drawLive() {
|
|
306
|
+
if (!isTTY || paused || disposed) return;
|
|
307
|
+
const lines = liveRegionLines();
|
|
308
|
+
if (lines.length === 0) {
|
|
309
|
+
clearLive();
|
|
310
|
+
write('\x1b[?25h');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const shrank = lines.length < renderedRows;
|
|
314
|
+
const body = lines.map(line => `\x1b[2K${line}`).join('\n');
|
|
315
|
+
// Skip the write entirely when nothing changed, so an idle block does not
|
|
316
|
+
// repaint 11 times a second for no visible reason.
|
|
317
|
+
if (!shrank && body === lastPainted) return;
|
|
318
|
+
lastPainted = body;
|
|
319
|
+
// Trailing \x1b[J drops rows left over from a taller previous frame.
|
|
320
|
+
write(`${cursorToBlockStart()}${body}${shrank ? '\x1b[J' : ''}\x1b[?25l`);
|
|
321
|
+
renderedRows = lines.length;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ensureTimer() {
|
|
325
|
+
if (timer || !isTTY || disposed) return;
|
|
326
|
+
const hasMotion = status || [...slots.values()].some(slot => slot.status === 'running');
|
|
327
|
+
if (!hasMotion) return;
|
|
328
|
+
timer = setInterval(() => {
|
|
329
|
+
frameIndex = (frameIndex + 1) % frames.length;
|
|
330
|
+
drawLive();
|
|
331
|
+
}, 120);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function stopTimer() {
|
|
335
|
+
if (timer) clearInterval(timer);
|
|
336
|
+
timer = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function refreshLive() {
|
|
340
|
+
if (!isTTY) return;
|
|
341
|
+
drawLive();
|
|
342
|
+
const hasMotion = status || [...slots.values()].some(slot => slot.status === 'running');
|
|
343
|
+
if (hasMotion) ensureTimer();
|
|
344
|
+
else stopTimer();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function setStatus(label, key = '') {
|
|
348
|
+
const text = sanitizeUntrustedText(label);
|
|
349
|
+
if (!text) return;
|
|
350
|
+
if (status && status.key === key) {
|
|
351
|
+
status.label = text;
|
|
352
|
+
} else {
|
|
353
|
+
status = { label: text, key, startedAt: now() };
|
|
354
|
+
}
|
|
355
|
+
if (!isTTY) {
|
|
356
|
+
write(`${text}\n`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
refreshLive();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function clearStatus() {
|
|
363
|
+
status = null;
|
|
364
|
+
if (isTTY) refreshLive();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function openSlot(id, tool, label) {
|
|
368
|
+
if (!id) return;
|
|
369
|
+
const existing = slots.get(id);
|
|
370
|
+
if (existing) {
|
|
371
|
+
existing.label = sanitizeUntrustedText(label) || existing.label;
|
|
372
|
+
existing.tool = tool || existing.tool;
|
|
373
|
+
} else {
|
|
374
|
+
slots.set(id, {
|
|
375
|
+
id,
|
|
376
|
+
tool: tool || '',
|
|
377
|
+
label: sanitizeUntrustedText(label) || toolTag(tool),
|
|
378
|
+
status: 'running',
|
|
379
|
+
startedAt: now(),
|
|
380
|
+
seq: slots.size
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// A tool row supersedes the narrative line; keeping both is redundant.
|
|
384
|
+
status = null;
|
|
385
|
+
if (!isTTY) {
|
|
386
|
+
write(`${sanitizeUntrustedText(label) || toolTag(tool)}\n`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
refreshLive();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function closeSlot(id, outcome, text, tool) {
|
|
393
|
+
const slot = id ? slots.get(id) : null;
|
|
394
|
+
if (slot) {
|
|
395
|
+
slot.status = outcome;
|
|
396
|
+
slot.duration = formatDuration(now() - slot.startedAt);
|
|
397
|
+
slots.delete(id);
|
|
398
|
+
}
|
|
399
|
+
const symbol = outcome === 'done' ? symbols.done : symbols.failed;
|
|
400
|
+
const tone = outcome === 'done' ? 'success' : 'error';
|
|
401
|
+
const duration = slot?.duration ? paint('muted', ` · ${slot.duration}`) : '';
|
|
402
|
+
commitLine(symbol, text, tone, false, duration, tool || slot?.tool);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Append a permanent log line above the live region. Anything already drawn
|
|
407
|
+
* in the live block is cleared first, then redrawn beneath the new line.
|
|
408
|
+
*/
|
|
409
|
+
function commitLine(symbol, text, tone = 'muted', force = false, suffix = '', tool = '') {
|
|
410
|
+
if (!force && completedCount >= maxCompleted) {
|
|
411
|
+
hiddenCount++;
|
|
412
|
+
if (isTTY) refreshLive();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
clearLive();
|
|
416
|
+
completedCount++;
|
|
417
|
+
const tag = tool ? `${paint('muted', padEnd(toolTag(tool), 7))} ` : '';
|
|
418
|
+
const head = `${paint(tone, symbol)} ${tag}`;
|
|
419
|
+
const available = Math.max(12, terminalWidth() - displayWidth(head) - displayWidth(suffix));
|
|
420
|
+
write(`${head}${truncateEnd(sanitizeUntrustedText(text), available)}${suffix}\n`);
|
|
421
|
+
if (isTTY) refreshLive();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function writeBlock(lines) {
|
|
425
|
+
clearLive();
|
|
426
|
+
for (const line of lines) write(`${line}\n`);
|
|
427
|
+
if (isTTY) refreshLive();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function finishAnswerLine() {
|
|
431
|
+
if (answerOpen && !answerEndsWithNewline) write('\n');
|
|
432
|
+
answerEndsWithNewline = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function beginAnswer() {
|
|
436
|
+
if (answerOpen) return;
|
|
437
|
+
clearLive();
|
|
438
|
+
stopTimer();
|
|
439
|
+
status = null;
|
|
440
|
+
write(`\n${paint('brand', '●')} `);
|
|
441
|
+
answerOpen = true;
|
|
442
|
+
answerEndsWithNewline = false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Colourize a unified-diff preview so additions and removals read instantly. */
|
|
446
|
+
function renderPreview(preview) {
|
|
447
|
+
const width = terminalWidth();
|
|
448
|
+
const lines = [];
|
|
449
|
+
for (const raw of sanitizeUntrustedText(preview).replace(/^\n/, '').split('\n')) {
|
|
450
|
+
const line = truncateEnd(raw, width - 2);
|
|
451
|
+
if (/^@@/.test(line)) lines.push(` ${paint('active', line)}`);
|
|
452
|
+
else if (/^\+/.test(line)) lines.push(` ${paint('success', line)}`);
|
|
453
|
+
else if (/^-/.test(line)) lines.push(` ${paint('error', line)}`);
|
|
454
|
+
else if (/^(?:Create|Update|Delete)\s/.test(line)) lines.push(` ${paint('accent', line)}`);
|
|
455
|
+
else lines.push(` ${paint('muted', line)}`);
|
|
456
|
+
}
|
|
457
|
+
writeBlock(lines);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function setPhase(name, label) {
|
|
461
|
+
const key = String(name || '').toLowerCase();
|
|
462
|
+
if (PHASE_ORDER.includes(key)) {
|
|
463
|
+
if (phase && phase !== key) phaseDone.add(phase);
|
|
464
|
+
phase = key;
|
|
465
|
+
}
|
|
466
|
+
setStatus(label || key.toUpperCase(), `phase-${key}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function handle(event) {
|
|
470
|
+
if (!event || disposed) return;
|
|
471
|
+
const data = event.data || {};
|
|
472
|
+
|
|
473
|
+
if (event.type === 'turn.started') {
|
|
474
|
+
setStatus(data.label || labels.analyzing, event.type);
|
|
475
|
+
} else if (event.type === 'phase.changed') {
|
|
476
|
+
setPhase(data.phase, data.label);
|
|
477
|
+
} else if (event.type === 'scan.started') {
|
|
478
|
+
setPhase('scan', cn ? 'SCAN · 正在建立项目索引' : 'SCAN · indexing workspace');
|
|
479
|
+
} else if (event.type === 'scan.progress') {
|
|
480
|
+
const files = Number(data.files) || 0;
|
|
481
|
+
const cache = files ? Math.round((Number(data.cacheHits) || 0) / files * 100) : 0;
|
|
482
|
+
setStatus(data.planning
|
|
483
|
+
? (cn ? `SCAN · 正在制定上下文预算 · ${files} 个文件` : `SCAN · planning context budget · ${files} files`)
|
|
484
|
+
: (cn ? `SCAN · ${files} 个文件 · 缓存 ${cache}%` : `SCAN · ${files} files · ${cache}% cached`), 'scan');
|
|
485
|
+
} else if (event.type === 'scan.inspect') {
|
|
486
|
+
setStatus(cn ? `SCAN · 检查结构 ${data.path}` : `SCAN · inspecting ${data.path}`, 'scan');
|
|
487
|
+
} else if (event.type === 'scan.index.completed') {
|
|
488
|
+
const mb = ((Number(data.bytes) || 0) / 1048576).toFixed(1);
|
|
489
|
+
const files = Number(data.files) || 0;
|
|
490
|
+
const cache = files ? Math.round((Number(data.cacheHits) || 0) / files * 100) : 0;
|
|
491
|
+
commitLine(symbols.done, cn
|
|
492
|
+
? `Scan · 工作区索引 · ${files} 个文件 · ${mb} MB · 缓存 ${cache}% · 排除 ${data.excluded || 0}`
|
|
493
|
+
: `Scan · workspace index · ${files} files · ${mb} MB · ${cache}% cached · ${data.excluded || 0} excluded`, 'success');
|
|
494
|
+
} else if (event.type === 'scan.notebook.checked') {
|
|
495
|
+
let detail;
|
|
496
|
+
if (!data.enabled) detail = cn ? '当前模式不使用项目笔记' : 'not used in this mode';
|
|
497
|
+
else if (!data.found) detail = cn ? '未找到 · 将在 READ 建立' : 'not found · will be created during READ';
|
|
498
|
+
else detail = cn
|
|
499
|
+
? `已找到 · ${data.status || 'ready'} · ${data.files || 0} 个文件`
|
|
500
|
+
: `found · ${data.status || 'ready'} · ${data.files || 0} files`;
|
|
501
|
+
commitLine(data.found ? symbols.done : '◇', cn
|
|
502
|
+
? `Scan · 项目笔记 · ${detail}`
|
|
503
|
+
: `Scan · project notebook · ${detail}`, data.found ? 'success' : 'muted');
|
|
504
|
+
} else if (event.type === 'scan.drift.completed') {
|
|
505
|
+
let detail;
|
|
506
|
+
if (!data.enabled) detail = cn ? '已略过' : 'skipped';
|
|
507
|
+
else if (data.action === 'failed') detail = cn ? '检查失败 · 已标记为陈旧' : 'check failed · marked stale';
|
|
508
|
+
else if (['built', 'resumed'].includes(data.action)) detail = cn ? '已建立新基线' : 'new baseline created';
|
|
509
|
+
else if ((Number(data.changes) || 0) === 0) detail = cn ? '无未记录变化' : 'no unrecorded changes';
|
|
510
|
+
else detail = cn ? `${data.changes} 处变化 · ${data.action}` : `${data.changes} changes · ${data.action}`;
|
|
511
|
+
commitLine(data.action === 'failed' ? symbols.warning : symbols.done, cn
|
|
512
|
+
? `Scan · 变更检查 · ${detail}`
|
|
513
|
+
: `Scan · drift check · ${detail}`, data.action === 'failed' ? 'warning' : 'success');
|
|
514
|
+
} else if (event.type === 'scan.completed') {
|
|
515
|
+
phaseDone.add('scan');
|
|
516
|
+
commitLine(symbols.done, cn
|
|
517
|
+
? 'Scan · 检查完成 · 进入正式读取'
|
|
518
|
+
: 'Scan · checks complete · entering formal reading', 'success');
|
|
519
|
+
} else if (event.type === 'scan.degraded') {
|
|
520
|
+
commitLine(symbols.warning, cn ? `Scan 预算规划降级 · ${data.error}` : `Scan planning degraded · ${data.error}`, 'warning');
|
|
521
|
+
} else if (event.type === 'scan.note.plan.started') {
|
|
522
|
+
setStatus(cn
|
|
523
|
+
? `SCAN · 正在规划低成本项目读取 · ${data.files || 0} 个文件`
|
|
524
|
+
: `SCAN · planning token-efficient project reading · ${data.files || 0} files`, 'scan-note-plan');
|
|
525
|
+
} else if (event.type === 'scan.note.plan.completed') {
|
|
526
|
+
const groups = data.plan?.groups || [];
|
|
527
|
+
const count = strategy => groups.filter(group => group.strategy === strategy).reduce((sum, group) => sum + (group.paths?.length || 0), 0);
|
|
528
|
+
commitLine(symbols.done, cn
|
|
529
|
+
? `Scan · 快速读取 · 文件大纲 ${count('outline') + count('read')} · 源码读取 ${count('read')}`
|
|
530
|
+
: `Scan · fast read · ${count('outline') + count('read')} outlines · ${count('read')} source reads`, 'success');
|
|
531
|
+
} else if (event.type === 'scan.note.plan.degraded') {
|
|
532
|
+
commitLine(symbols.warning, cn
|
|
533
|
+
? 'Scan · 读取方案已使用保守回退'
|
|
534
|
+
: 'Scan · conservative read-plan fallback applied', 'warning');
|
|
535
|
+
} else if (event.type === 'context.planned') {
|
|
536
|
+
const plan = data.contextPlan || {};
|
|
537
|
+
const budget = Math.round((Number(plan.contextBudgetTokens || plan.budgetTokens) || 0) / 1000);
|
|
538
|
+
const turnBudget = Math.round((Number(plan.turnBudgetTokens || plan.budgetTokens) || 0) / 1000);
|
|
539
|
+
const window = Math.round((Number(plan.contextWindowTokens) || 0) / 1000);
|
|
540
|
+
const verify = plan.files?.length || plan.notebookReuse?.verifyFiles?.length || plan.recommendedFiles?.length || 0;
|
|
541
|
+
const source = plan.notebook?.action === 'reuse' || plan.notebookReuse
|
|
542
|
+
? (cn ? '笔记优先' : 'notebook first')
|
|
543
|
+
: (cn ? '索引导向' : 'index guided');
|
|
544
|
+
commitLine(symbols.done, cn
|
|
545
|
+
? `Scan · 上下文方案 · ${source} · ${budget}k/${window}k · 建议核对 ${verify} 个文件`
|
|
546
|
+
: `Scan · context plan · ${source} · ${budget}k/${window}k per call · ${turnBudget}k turn · verify ${verify} files`, 'success');
|
|
547
|
+
} else if (event.type === 'context.usage') {
|
|
548
|
+
const used = formatTokens(data.usedTokens);
|
|
549
|
+
const budget = formatTokens(data.budgetTokens);
|
|
550
|
+
gauge = cn ? `上下文 ${used}/${budget}` : `context ${used}/${budget}`;
|
|
551
|
+
if (isTTY) refreshLive();
|
|
552
|
+
else setStatus(cn ? `READ · 上下文 ${used}/${budget}` : `READ · Context ${used}/${budget}`, 'context');
|
|
553
|
+
} else if (event.type === 'model.completed') {
|
|
554
|
+
const cacheRead = Number(data.cacheReadInputTokens) || 0;
|
|
555
|
+
const input = Number(data.inputTokens) || 0;
|
|
556
|
+
if (cacheRead > 0 && input + cacheRead > 0) {
|
|
557
|
+
const hit = Math.round(cacheRead / (input + cacheRead) * 100);
|
|
558
|
+
const base = gauge ? gauge.split(' · ')[0] : '';
|
|
559
|
+
gauge = base ? `${base} · ${labels.cache} ${hit}%` : `${labels.cache} ${hit}%`;
|
|
560
|
+
if (isTTY) refreshLive();
|
|
561
|
+
}
|
|
562
|
+
} else if (event.type === 'note.build.started') {
|
|
563
|
+
setStatus(cn
|
|
564
|
+
? `READ · ${data.continuing ? '继续读取项目' : '首次读取项目'} · ${data.remaining || data.total || 0} 个文件`
|
|
565
|
+
: `READ · ${data.continuing ? 'resuming project reading' : 'initial project reading'} · ${data.remaining || data.total || 0} files`, 'project-read');
|
|
566
|
+
} else if (event.type === 'note.build.progress') {
|
|
567
|
+
const strategy = data.strategy === 'read'
|
|
568
|
+
? (cn ? '读取源码' : 'source read')
|
|
569
|
+
: (cn ? '建立大纲' : 'outline');
|
|
570
|
+
setStatus(`READ · ${data.completed || 0}/${data.total || 0} · ${strategy}`, 'project-read');
|
|
571
|
+
} else if (event.type === 'note.synthesis.started') {
|
|
572
|
+
setStatus(cn
|
|
573
|
+
? `READ · 正在整理项目认知 · ${data.files || 0} 个文件`
|
|
574
|
+
: `READ · organizing project knowledge · ${data.files || 0} files`, 'note-synthesis');
|
|
575
|
+
} else if (event.type === 'note.synthesis.completed') {
|
|
576
|
+
setStatus(cn ? 'READ · 项目认知整理完成' : 'READ · project knowledge organized', 'note-synthesis-complete');
|
|
577
|
+
} else if (event.type === 'note.build.completed') {
|
|
578
|
+
commitLine(symbols.done, cn
|
|
579
|
+
? `Read · 项目认知已缓存 · ${data.files || 0} 个文件`
|
|
580
|
+
: `Read · project knowledge cached · ${data.files || 0} files`, 'success');
|
|
581
|
+
} else if (event.type === 'read.file.completed') {
|
|
582
|
+
commitLine(symbols.done, data.kind === 'read'
|
|
583
|
+
? (cn ? `工具 · 已读取 ${data.path} · ${data.lines || 0} 行` : `Tool · read ${data.path} · ${data.lines || 0} lines`)
|
|
584
|
+
: (cn ? `工具 · 已建立 ${data.path} 的文件大纲` : `Tool · outlined ${data.path}`), 'success');
|
|
585
|
+
} else if (event.type === 'note.loaded') {
|
|
586
|
+
setStatus(cn ? 'SCAN · 使用已有项目笔记' : 'SCAN · using existing project notebook', 'note-load');
|
|
587
|
+
} else if (event.type === 'note.drift') {
|
|
588
|
+
commitLine(symbols.warning, cn
|
|
589
|
+
? `Note · ${data.count || 0} 个未记录变化`
|
|
590
|
+
: `Note · ${data.count || 0} unrecorded changes`, 'warning');
|
|
591
|
+
} else if (event.type === 'note.update.started') {
|
|
592
|
+
setStatus(cn ? `READ · 正在核对 ${data.files || 0} 个变化` : `READ · verifying ${data.files || 0} changes`, 'project-read-update');
|
|
593
|
+
} else if (event.type === 'note.update.completed') {
|
|
594
|
+
commitLine(symbols.done, cn
|
|
595
|
+
? `Note · 已更新 · ${data.files || 0} 个文件 · ${data.sections || 0} 个章节`
|
|
596
|
+
: `Note · updated · ${data.files || 0} files · ${data.sections || 0} sections`, 'success');
|
|
597
|
+
} else if (event.type === 'note.failed') {
|
|
598
|
+
commitLine(symbols.warning, cn
|
|
599
|
+
? `Note · 更新失败,已标记为 ${data.status || 'stale'}`
|
|
600
|
+
: `Note · update failed; marked ${data.status || 'stale'}`, 'warning', true);
|
|
601
|
+
} else if (event.type === 'context.compacted') {
|
|
602
|
+
const detail = data.omittedMessages
|
|
603
|
+
? (cn ? ` · 省略 ${data.omittedMessages} 条旧消息` : ` · ${data.omittedMessages} older messages omitted`)
|
|
604
|
+
: '';
|
|
605
|
+
commitLine(symbols.done, `${labels.compacted}${detail}`, 'muted');
|
|
606
|
+
} else if (event.type === 'memory.recalled') {
|
|
607
|
+
commitLine('◆', cn ? `已回忆 ${Number(data.count) || 0} 条相关工作区记忆` : `Recalled ${Number(data.count) || 0} relevant workspace memories`, 'muted');
|
|
608
|
+
} else if (event.type === 'memory.saved') {
|
|
609
|
+
commitLine('◆', cn ? `已保存 ${Number(data.count) || 0} 条工作区记忆` : `Saved ${Number(data.count) || 0} workspace memories`, 'muted');
|
|
610
|
+
} else if (event.type === 'model.started') {
|
|
611
|
+
if (!answerOpen) setStatus(data.label || (data.phase === 'finalizing' ? labels.organizing : labels.analyzing), event.type);
|
|
612
|
+
} else if (event.type === 'model.retry') {
|
|
613
|
+
setStatus(data.label || labels.retrying, event.type);
|
|
614
|
+
} else if (event.type === 'model.delta') {
|
|
615
|
+
const text = sanitizeUntrustedText(data.text || '');
|
|
616
|
+
if (!text) return;
|
|
617
|
+
beginAnswer();
|
|
618
|
+
const rendered = renderTerminalMarkdown(text, { color: useColor, width: terminalWidth() - 4 });
|
|
619
|
+
write(rendered);
|
|
620
|
+
answerEndsWithNewline = rendered.endsWith('\n');
|
|
621
|
+
} else if (event.type === 'tool.requested') {
|
|
622
|
+
openSlot(data.toolCallId, data.tool, data.label || data.displaySummary || data.tool);
|
|
623
|
+
} else if (event.type === 'todos.updated') {
|
|
624
|
+
todos = Array.isArray(data.todos) ? data.todos : [];
|
|
625
|
+
if (isTTY) refreshLive();
|
|
626
|
+
else for (const line of todoLines()) write(`${line}\n`);
|
|
627
|
+
} else if (event.type === 'tool.started') {
|
|
628
|
+
openSlot(data.toolCallId, data.tool, data.label || data.displaySummary || data.tool);
|
|
629
|
+
} else if (event.type === 'tool.progress') {
|
|
630
|
+
const slot = data.toolCallId ? slots.get(data.toolCallId) : null;
|
|
631
|
+
const progress = Number.isFinite(data.percent) ? ` · ${data.percent}%` : '';
|
|
632
|
+
const label = `${sanitizeUntrustedText(data.label || data.displaySummary || data.tool || '')}${progress}`;
|
|
633
|
+
if (slot) {
|
|
634
|
+
slot.label = label;
|
|
635
|
+
refreshLive();
|
|
636
|
+
} else {
|
|
637
|
+
openSlot(data.toolCallId, data.tool, label);
|
|
638
|
+
}
|
|
639
|
+
} else if (event.type === 'tool.completed') {
|
|
640
|
+
const prefix = data.tool ? '' : (cn ? '工具 · ' : 'Tool · ');
|
|
641
|
+
closeSlot(data.toolCallId, 'done', `${prefix}${data.displaySummary || data.tool || 'Done'}`, data.tool);
|
|
642
|
+
} else if (event.type === 'tool.failed') {
|
|
643
|
+
closeSlot(data.toolCallId, 'failed', data.displaySummary || data.error || data.tool || labels.failed, data.tool);
|
|
644
|
+
} else if (event.type === 'permission.requested') {
|
|
645
|
+
clearLive();
|
|
646
|
+
stopTimer();
|
|
647
|
+
write(`${paint('warning', symbols.warning)} ${sanitizeUntrustedText(data.displaySummary || 'Confirmation required')}\n`);
|
|
648
|
+
if (data.preview) renderPreview(data.preview);
|
|
649
|
+
} else if (event.type === 'permission.resolved') {
|
|
650
|
+
if (!data.allowed) commitLine(symbols.warning, data.displaySummary || labels.cancelled, 'warning');
|
|
651
|
+
} else if (event.type === 'turn.failed') {
|
|
652
|
+
clearLive();
|
|
653
|
+
stopTimer();
|
|
654
|
+
finishAnswerLine();
|
|
655
|
+
commitLine(symbols.failed, data.error || labels.failed, 'error', true);
|
|
656
|
+
} else if (event.type === 'turn.cancelled') {
|
|
657
|
+
clearLive();
|
|
658
|
+
stopTimer();
|
|
659
|
+
finishAnswerLine();
|
|
660
|
+
commitLine(symbols.warning, data.reason || labels.cancelled, 'warning', true);
|
|
661
|
+
} else if (event.type === 'turn.completed') {
|
|
662
|
+
if (phase) phaseDone.add(phase);
|
|
663
|
+
const finalTodos = todoLines();
|
|
664
|
+
slots.clear();
|
|
665
|
+
status = null;
|
|
666
|
+
todos = [];
|
|
667
|
+
clearLive();
|
|
668
|
+
stopTimer();
|
|
669
|
+
if (isTTY) write('\x1b[?25h');
|
|
670
|
+
finishAnswerLine();
|
|
671
|
+
// Persist the closing checklist so it survives the cleared live region.
|
|
672
|
+
for (const line of finalTodos) write(`${line}\n`);
|
|
673
|
+
if (hiddenCount > 0) write(`${paint('muted', symbols.more)} ${hiddenCount} ${labels.extra}\n`);
|
|
674
|
+
if (answerOpen || completedCount > 0) write('\n');
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function pause() {
|
|
679
|
+
paused = true;
|
|
680
|
+
stopTimer();
|
|
681
|
+
clearLive();
|
|
682
|
+
if (isTTY) write('\x1b[?25h');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function resume() {
|
|
686
|
+
if (disposed) return;
|
|
687
|
+
paused = false;
|
|
688
|
+
refreshLive();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function dispose() {
|
|
692
|
+
if (disposed) return;
|
|
693
|
+
clearLive();
|
|
694
|
+
stopTimer();
|
|
695
|
+
if (isTTY) write('\x1b[?25h');
|
|
696
|
+
finishAnswerLine();
|
|
697
|
+
disposed = true;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return { handle, pause, resume, dispose };
|
|
701
|
+
}
|