@@ -18,6 +18,77 @@ import { readFileSync } from 'fs';
1818import { isInteractive } from '../../utils/env' ;
1919import { 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+
2192interface 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