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.
- package/README.md +25 -6
- package/bin/aiClient.js +292 -67
- package/bin/check.js +11 -0
- package/bin/cliMenu.js +205 -68
- package/bin/commandRouter.js +20 -0
- package/bin/configManager.js +71 -47
- package/bin/contextManager.js +107 -91
- package/bin/index.js +1413 -243
- package/bin/markdownRenderer.js +264 -0
- package/bin/memoryManager.js +182 -0
- package/bin/planManager.js +61 -9
- package/bin/projectNotebookManager.js +839 -0
- package/bin/runtimeEvents.js +42 -0
- package/bin/scanManager.js +561 -0
- package/bin/sessionManager.js +17 -1
- package/bin/terminalRenderer.js +543 -125
- 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 +688 -133
- package/package.json +3 -5
package/bin/terminalRenderer.js
CHANGED
|
@@ -1,69 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
129
|
-
if (!
|
|
130
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
142
|
-
if (
|
|
143
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
195
|
-
|
|
437
|
+
clearLive();
|
|
438
|
+
stopTimer();
|
|
439
|
+
status = null;
|
|
440
|
+
write(`\n${paint('brand', '●')} `);
|
|
196
441
|
answerOpen = true;
|
|
197
|
-
answerEndsWithNewline =
|
|
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
|
-
|
|
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
|
-
? (
|
|
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)
|
|
611
|
+
if (!answerOpen) setStatus(data.label || (data.phase === 'finalizing' ? labels.organizing : labels.analyzing), event.type);
|
|
213
612
|
} else if (event.type === 'model.retry') {
|
|
214
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
231
|
-
|
|
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
|
-
|
|
643
|
+
closeSlot(data.toolCallId, 'failed', data.displaySummary || data.error || data.tool || labels.failed, data.tool);
|
|
234
644
|
} else if (event.type === 'permission.requested') {
|
|
235
|
-
|
|
645
|
+
clearLive();
|
|
646
|
+
stopTimer();
|
|
236
647
|
write(`${paint('warning', symbols.warning)} ${sanitizeUntrustedText(data.displaySummary || 'Confirmation required')}\n`);
|
|
237
|
-
if (data.preview)
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
+
clearLive();
|
|
694
|
+
stopTimer();
|
|
695
|
+
if (isTTY) write('\x1b[?25h');
|
|
278
696
|
finishAnswerLine();
|
|
279
697
|
disposed = true;
|
|
280
698
|
}
|