dave-code 1.1.0 → 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.
@@ -1,69 +1,88 @@
1
- const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]/g;
2
- const OSC_RE = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
3
- const STRING_CONTROL_RE = /\x1b[PX^_][\s\S]*?\x1b\\/g;
4
- const ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|.)/g;
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 };
5
15
 
6
16
  const COLORS = {
7
17
  brand: '\x1b[1;38;2;250;100;30m',
8
18
  active: '\x1b[36m',
19
+ userBackground: '\x1b[48;5;236m',
9
20
  success: '\x1b[32m',
10
21
  warning: '\x1b[33m',
11
22
  error: '\x1b[31m',
12
23
  muted: '\x1b[90m',
24
+ accent: '\x1b[38;2;250;100;30m',
13
25
  reset: '\x1b[0m'
14
26
  };
15
27
 
16
- export function stripAnsi(text) {
17
- return String(text || '').replace(ANSI_RE, '');
18
- }
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
+ };
19
33
 
20
- export function sanitizeUntrustedText(text) {
21
- return String(text ?? '')
22
- .replace(OSC_RE, '')
23
- .replace(STRING_CONTROL_RE, '')
24
- .replace(ESCAPE_RE, '')
25
- .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '');
26
- }
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 '';
27
43
 
28
- export function displayWidth(text) {
29
- let width = 0;
30
- for (const char of stripAnsi(text)) {
31
- const code = char.codePointAt(0);
32
- if (
33
- (code >= 0x1100 && code <= 0x115f) ||
34
- (code >= 0x2e80 && code <= 0xa4cf) ||
35
- (code >= 0xac00 && code <= 0xd7a3) ||
36
- (code >= 0xf900 && code <= 0xfaff) ||
37
- (code >= 0xfe10 && code <= 0xfe6f) ||
38
- (code >= 0xff00 && code <= 0xff60) ||
39
- (code >= 0x1f300 && code <= 0x1faff)
40
- ) {
41
- width += 2;
42
- } else {
43
- width += 1;
44
- }
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('')}`;
45
52
  }
46
- return width;
47
- }
48
53
 
49
- export function truncateMiddle(text, maxWidth) {
50
- const value = String(text || '');
51
- if (displayWidth(value) <= maxWidth) return value;
52
- if (maxWidth <= 3) return '.'.repeat(Math.max(0, maxWidth));
53
- const target = maxWidth - 3;
54
- const leftTarget = Math.ceil(target / 2);
55
- const rightTarget = Math.floor(target / 2);
56
- let left = '';
57
- let right = '';
58
- for (const char of value) {
59
- if (displayWidth(left + char) > leftTarget) break;
60
- left += char;
61
- }
62
- for (const char of [...value].reverse()) {
63
- if (displayWidth(char + right) > rightTarget) break;
64
- right = char + right;
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
+ }
65
76
  }
66
- return `${left}...${right}`;
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');
67
86
  }
68
87
 
69
88
  function formatDuration(ms) {
@@ -72,6 +91,35 @@ function formatDuration(ms) {
72
91
  return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s`;
73
92
  }
74
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
+
75
123
  export function createTerminalRenderer({
76
124
  stdout = process.stdout,
77
125
  lang = 'cn',
@@ -86,9 +134,10 @@ export function createTerminalRenderer({
86
134
  ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
87
135
  : ['-', '\\', '|', '/'];
88
136
  const symbols = useUnicode
89
- ? { done: '✓', failed: '×', warning: '!', more: '…' }
90
- : { done: '+', failed: 'x', warning: '!', more: '...' };
91
- const labels = lang === 'cn'
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
92
141
  ? {
93
142
  analyzing: '思考中',
94
143
  organizing: '正在整理结果',
@@ -96,7 +145,11 @@ export function createTerminalRenderer({
96
145
  compacted: '已压缩上下文',
97
146
  cancelled: '操作已取消',
98
147
  failed: '执行失败',
99
- extra: '另有步骤已完成'
148
+ extra: '另有步骤已完成',
149
+ parallel: '并行执行',
150
+ tools: '个工具',
151
+ todos: '任务清单',
152
+ cache: '缓存'
100
153
  }
101
154
  : {
102
155
  analyzing: 'Thinking',
@@ -105,11 +158,14 @@ export function createTerminalRenderer({
105
158
  compacted: 'Context compacted',
106
159
  cancelled: 'Operation cancelled',
107
160
  failed: 'Turn failed',
108
- extra: 'additional steps completed'
161
+ extra: 'additional steps completed',
162
+ parallel: 'Running',
163
+ tools: 'tools in parallel',
164
+ todos: 'Task checklist',
165
+ cache: 'cache'
109
166
  };
110
167
 
111
168
  let frameIndex = 0;
112
- let active = null;
113
169
  let timer = null;
114
170
  let paused = false;
115
171
  let disposed = false;
@@ -117,30 +173,162 @@ export function createTerminalRenderer({
117
173
  let answerEndsWithNewline = true;
118
174
  let completedCount = 0;
119
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 = [];
120
188
 
121
189
  const paint = (name, text) => useColor ? `${COLORS[name]}${text}${COLORS.reset}` : text;
122
- const write = (text) => stdout.write(String(text));
190
+ const write = text => stdout.write(String(text));
123
191
 
124
192
  function terminalWidth() {
125
193
  return Math.max(24, stdout.columns || 80);
126
194
  }
127
195
 
128
- function elapsedText() {
129
- if (!active || terminalWidth() < 58) return '';
130
- return formatDuration(now() - active.startedAt);
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}`;
131
214
  }
132
215
 
133
- function buildActiveText() {
134
- if (!active) return '';
135
- const elapsed = elapsedText();
136
- const suffix = elapsed ? ` · ${elapsed}` : '';
137
- const available = Math.max(10, terminalWidth() - displayWidth(frames[frameIndex]) - 2);
138
- return `${frames[frameIndex]} ${truncateMiddle(`${active.label}${suffix}`, available)}`;
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;
139
322
  }
140
323
 
141
- function drawActive() {
142
- if (!active || paused || disposed || !isTTY) return;
143
- write(`\r\x1b[2K${paint('active', buildActiveText())}\x1b[?25l`);
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);
144
332
  }
145
333
 
146
334
  function stopTimer() {
@@ -148,40 +336,95 @@ export function createTerminalRenderer({
148
336
  timer = null;
149
337
  }
150
338
 
151
- function clearActive({ keep = false } = {}) {
152
- stopTimer();
153
- if (isTTY) write('\r\x1b[2K\x1b[?25h');
154
- if (!keep) active = null;
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();
155
345
  }
156
346
 
157
- function setActive(label, key = '') {
158
- if (active && active.key === key && !paused) {
159
- active.label = label;
160
- drawActive();
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`);
161
357
  return;
162
358
  }
163
- clearActive();
164
- active = { label, key, startedAt: now() };
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;
165
385
  if (!isTTY) {
166
- write(`${label}\n`);
386
+ write(`${sanitizeUntrustedText(label) || toolTag(tool)}\n`);
167
387
  return;
168
388
  }
169
- drawActive();
170
- timer = setInterval(() => {
171
- frameIndex = (frameIndex + 1) % frames.length;
172
- drawActive();
173
- }, 90);
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);
174
403
  }
175
404
 
176
- function commitLine(symbol, text, tone = 'muted') {
177
- clearActive();
178
- if (completedCount >= maxCompleted) {
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) {
179
411
  hiddenCount++;
412
+ if (isTTY) refreshLive();
180
413
  return;
181
414
  }
415
+ clearLive();
182
416
  completedCount++;
183
- const available = Math.max(12, terminalWidth() - displayWidth(symbol) - 2);
184
- write(`${paint(tone, symbol)} ${truncateMiddle(sanitizeUntrustedText(text), available)}\n`);
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();
185
428
  }
186
429
 
187
430
  function finishAnswerLine() {
@@ -191,10 +434,36 @@ export function createTerminalRenderer({
191
434
 
192
435
  function beginAnswer() {
193
436
  if (answerOpen) return;
194
- clearActive();
195
- write(`\n${paint('brand', 'Dave')}\n`);
437
+ clearLive();
438
+ stopTimer();
439
+ status = null;
440
+ write(`\n${paint('brand', '●')} `);
196
441
  answerOpen = true;
197
- answerEndsWithNewline = 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}`);
198
467
  }
199
468
 
200
469
  function handle(event) {
@@ -202,79 +471,228 @@ export function createTerminalRenderer({
202
471
  const data = event.data || {};
203
472
 
204
473
  if (event.type === 'turn.started') {
205
- setActive(data.label || labels.analyzing, event.type);
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);
206
601
  } else if (event.type === 'context.compacted') {
207
602
  const detail = data.omittedMessages
208
- ? (lang === 'cn' ? ` · 省略 ${data.omittedMessages} 条旧消息` : ` · ${data.omittedMessages} older messages omitted`)
603
+ ? (cn ? ` · 省略 ${data.omittedMessages} 条旧消息` : ` · ${data.omittedMessages} older messages omitted`)
209
604
  : '';
210
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');
211
610
  } else if (event.type === 'model.started') {
212
- if (!answerOpen) setActive(data.label || (data.phase === 'finalizing' ? labels.organizing : labels.analyzing), event.type);
611
+ if (!answerOpen) setStatus(data.label || (data.phase === 'finalizing' ? labels.organizing : labels.analyzing), event.type);
213
612
  } else if (event.type === 'model.retry') {
214
- setActive(data.label || labels.retrying, event.type);
613
+ setStatus(data.label || labels.retrying, event.type);
215
614
  } else if (event.type === 'model.delta') {
216
615
  const text = sanitizeUntrustedText(data.text || '');
217
616
  if (!text) return;
218
- clearActive();
219
617
  beginAnswer();
220
- write(text);
221
- answerEndsWithNewline = text.endsWith('\n');
618
+ const rendered = renderTerminalMarkdown(text, { color: useColor, width: terminalWidth() - 4 });
619
+ write(rendered);
620
+ answerEndsWithNewline = rendered.endsWith('\n');
222
621
  } else if (event.type === 'tool.requested') {
223
- setActive(sanitizeUntrustedText(data.label || data.displaySummary || data.tool || ''), data.toolCallId || event.type);
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`);
224
627
  } else if (event.type === 'tool.started') {
225
- setActive(sanitizeUntrustedText(data.label || data.displaySummary || data.tool || ''), data.toolCallId || event.type);
628
+ openSlot(data.toolCallId, data.tool, data.label || data.displaySummary || data.tool);
226
629
  } else if (event.type === 'tool.progress') {
630
+ const slot = data.toolCallId ? slots.get(data.toolCallId) : null;
227
631
  const progress = Number.isFinite(data.percent) ? ` · ${data.percent}%` : '';
228
- setActive(`${sanitizeUntrustedText(data.label || data.displaySummary || data.tool || '')}${progress}`, data.toolCallId || event.type);
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
+ }
229
639
  } else if (event.type === 'tool.completed') {
230
- const prefix = lang === 'cn' ? '工具 · ' : 'Tool · ';
231
- commitLine(symbols.done, `${prefix}${data.displaySummary || data.tool || 'Done'}`, 'success');
640
+ const prefix = data.tool ? '' : (cn ? '工具 · ' : 'Tool · ');
641
+ closeSlot(data.toolCallId, 'done', `${prefix}${data.displaySummary || data.tool || 'Done'}`, data.tool);
232
642
  } else if (event.type === 'tool.failed') {
233
- commitLine(symbols.failed, data.displaySummary || data.error || data.tool || labels.failed, 'error');
643
+ closeSlot(data.toolCallId, 'failed', data.displaySummary || data.error || data.tool || labels.failed, data.tool);
234
644
  } else if (event.type === 'permission.requested') {
235
- clearActive();
645
+ clearLive();
646
+ stopTimer();
236
647
  write(`${paint('warning', symbols.warning)} ${sanitizeUntrustedText(data.displaySummary || 'Confirmation required')}\n`);
237
- if (data.preview) write(`${sanitizeUntrustedText(data.preview).replace(/^\n/, '')}\n`);
648
+ if (data.preview) renderPreview(data.preview);
238
649
  } else if (event.type === 'permission.resolved') {
239
650
  if (!data.allowed) commitLine(symbols.warning, data.displaySummary || labels.cancelled, 'warning');
240
651
  } else if (event.type === 'turn.failed') {
652
+ clearLive();
653
+ stopTimer();
241
654
  finishAnswerLine();
242
- commitLine(symbols.failed, data.error || labels.failed, 'error');
655
+ commitLine(symbols.failed, data.error || labels.failed, 'error', true);
243
656
  } else if (event.type === 'turn.cancelled') {
657
+ clearLive();
658
+ stopTimer();
244
659
  finishAnswerLine();
245
- commitLine(symbols.warning, data.reason || labels.cancelled, 'warning');
660
+ commitLine(symbols.warning, data.reason || labels.cancelled, 'warning', true);
246
661
  } else if (event.type === 'turn.completed') {
247
- clearActive();
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');
248
670
  finishAnswerLine();
249
- if (hiddenCount > 0) {
250
- write(`${paint('muted', symbols.more)} ${hiddenCount} ${labels.extra}\n`);
251
- }
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`);
252
674
  if (answerOpen || completedCount > 0) write('\n');
253
675
  }
254
676
  }
255
677
 
256
678
  function pause() {
257
679
  paused = true;
258
- clearActive({ keep: true });
680
+ stopTimer();
681
+ clearLive();
682
+ if (isTTY) write('\x1b[?25h');
259
683
  }
260
684
 
261
685
  function resume() {
262
686
  if (disposed) return;
263
687
  paused = false;
264
- if (active) {
265
- drawActive();
266
- if (isTTY && !timer) {
267
- timer = setInterval(() => {
268
- frameIndex = (frameIndex + 1) % frames.length;
269
- drawActive();
270
- }, 90);
271
- }
272
- }
688
+ refreshLive();
273
689
  }
274
690
 
275
691
  function dispose() {
276
692
  if (disposed) return;
277
- clearActive();
693
+ clearLive();
694
+ stopTimer();
695
+ if (isTTY) write('\x1b[?25h');
278
696
  finishAnswerLine();
279
697
  disposed = true;
280
698
  }