Skip to content

Commit fca8aa8

Browse files
NianJiuZstclaude
andcommitted
feat: replace raw thinking output with animated color-cycling indicator
Instead of dumping raw thinking tokens to the terminal, show a dynamic indicator that conveys the model is working: - Braille spinner character cycles through 10 frames (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) - "Thinking (Xs)" label with real-time elapsed seconds - Hue-rotating color applied to both spinner and label independently for a smooth, continuous animation effect - Indicator renders on stderr via \r (carriage return) to keep stdout clean for piped output - Cleanly cleared when thinking phase ends and "Response:" is shown The thinking_delta events are now intentionally ignored — only the text_delta content reaches stdout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b523aa commit fca8aa8

1 file changed

Lines changed: 80 additions & 8 deletions

File tree

src/commands/text/chat.ts

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,77 @@ import { readFileSync } from 'fs';
1818
import { isInteractive } from '../../utils/env';
1919
import { promptText, failIfMissing } from '../../utils/prompt';
2020

21+
// ---------------------------------------------------------------------------
22+
// Thinking indicator — dynamic spinner + color-cycling label
23+
// ---------------------------------------------------------------------------
24+
25+
const BRAILLE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
26+
27+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
28+
const c = (1 - Math.abs(2 * l - 1)) * s;
29+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
30+
const m = l - c / 2;
31+
let r = 0, g = 0, b = 0;
32+
if (h < 60) { r = c; g = x; }
33+
else if (h < 120) { r = x; g = c; }
34+
else if (h < 180) { g = c; b = x; }
35+
else if (h < 240) { g = x; b = c; }
36+
else if (h < 300) { r = x; b = c; }
37+
else { r = c; b = x; }
38+
return [
39+
Math.round((r + m) * 255),
40+
Math.round((g + m) * 255),
41+
Math.round((b + m) * 255),
42+
];
43+
}
44+
45+
class ThinkingIndicator {
46+
private frame = 0;
47+
private startTime = 0;
48+
private timer: ReturnType<typeof setInterval> | null = null;
49+
private out: NodeJS.WriteStream;
50+
private noColor: boolean;
51+
52+
constructor(out: NodeJS.WriteStream, noColor: boolean) {
53+
this.out = out;
54+
this.noColor = noColor;
55+
}
56+
57+
start(): void {
58+
this.frame = 0;
59+
this.startTime = Date.now();
60+
this.tick();
61+
this.timer = setInterval(() => this.tick(), 80);
62+
}
63+
64+
stop(): void {
65+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
66+
this.out.write('\r\x1b[0K');
67+
}
68+
69+
private tick(): void {
70+
const ch = BRAILLE[this.frame % BRAILLE.length];
71+
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
72+
const label = elapsed >= 60
73+
? `Thinking (${Math.floor(elapsed / 60)}m ${elapsed % 60}s)`
74+
: `Thinking (${elapsed}s)`;
75+
76+
this.frame++;
77+
78+
if (this.noColor) {
79+
this.out.write(`\r${ch} ${label}\x1b[0K`);
80+
} else {
81+
const [rs, gs, bs] = hslToRgb((this.frame * 17) % 360, 0.85, 0.55);
82+
// Hue-shift the spinner slightly ahead of the label for contrast
83+
const [rl, gl, bl] = hslToRgb(((this.frame * 17) + 40) % 360, 0.85, 0.55);
84+
this.out.write(
85+
`\r\x1b[38;2;${rs};${gs};${bs}m${ch}\x1b[0m ` +
86+
`\x1b[38;2;${rl};${gl};${bl}m${label}\x1b[0m\x1b[0K`,
87+
);
88+
}
89+
}
90+
}
91+
2192
interface ParsedMessages {
2293
system?: string;
2394
messages: ChatMessage[];
@@ -196,11 +267,11 @@ export default defineCommand({
196267
const reset = config.noColor ? '' : '\x1b[0m';
197268
const isJsonOutput = format === 'json';
198269
const isTTY = process.stdout.isTTY;
199-
// In TTY mode, write thinking/response headers to stdout for display.
200-
// In non-TTY (pipe/agent) mode, write everything but final text to stderr.
201-
const statusOut = isTTY && !isJsonOutput ? process.stdout : process.stderr;
270+
const statusOut = isTTY && !isJsonOutput ? process.stderr : process.stderr;
202271
const resultOut = isJsonOutput ? undefined : process.stdout;
203272

273+
const think = new ThinkingIndicator(statusOut, config.noColor);
274+
204275
for await (const event of parseSSE(res)) {
205276
if (event.data === '[DONE]') break;
206277
try {
@@ -209,25 +280,26 @@ export default defineCommand({
209280
if (parsed.type === 'content_block_start') {
210281
if (parsed.content_block.type === 'thinking') {
211282
inThinking = true;
212-
statusOut.write(`${dim}Thinking:\n`);
283+
think.start();
213284
} else if (parsed.content_block.type === 'text' && inThinking) {
214-
statusOut.write(`${reset}\n\nResponse:\n`);
285+
think.stop();
215286
inThinking = false;
287+
statusOut.write(`${reset}\nResponse:\n`);
216288
}
217289
} else if (parsed.type === 'content_block_delta') {
218290
if (parsed.delta.type === 'text_delta') {
219291
textContent += parsed.delta.text;
220292
resultOut?.write(parsed.delta.text);
221-
} else if (parsed.delta.type === 'thinking_delta') {
222-
statusOut.write(parsed.delta.thinking);
223293
}
294+
// thinking_delta is intentionally ignored — the dynamic
295+
// spinner conveys activity without dumping raw thought text.
224296
}
225297
} catch (err) {
226298
// Warn but don't crash — partial output is better than nothing
227299
process.stderr.write(`\n${dim}[warning] Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}${reset}\n`);
228300
}
229301
}
230-
if (inThinking) statusOut.write(reset);
302+
if (inThinking) think.stop();
231303

232304
if (format === 'json') {
233305
console.log(formatOutput({ content: textContent }, format));

0 commit comments

Comments
 (0)