Skip to content

Commit b804073

Browse files
committed
fix markdown spinner glitch
1 parent 4fc28de commit b804073

File tree

4 files changed

+41
-101
lines changed

4 files changed

+41
-101
lines changed

npm-app/src/display/markdown-renderer.ts

Lines changed: 10 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { highlight } from 'cli-highlight'
12
import MarkdownIt from 'markdown-it'
23
// @ts-ignore: Type definitions not available for markdown-it-terminal
34
import terminal from 'markdown-it-terminal'
4-
import { highlight } from 'cli-highlight'
5-
import { gray } from 'picocolors'
65
import wrapAnsi from 'wrap-ansi'
76

7+
import { Spinner } from '../utils/spinner'
8+
89
export type MarkdownStreamRendererOptions = {
910
width?: number
1011
isTTY?: boolean
@@ -39,7 +40,6 @@ export class MarkdownStreamRenderer {
3940
private width: number
4041
private isTTY: boolean
4142
private syntaxHighlight: boolean
42-
private maxBufferBytes: number
4343
private streamingMode: 'smart' | 'conservative'
4444
private md: MarkdownIt
4545

@@ -48,33 +48,14 @@ export class MarkdownStreamRenderer {
4848
private lookaheadBuffer = ''
4949
private consumedIndex = 0
5050
private sourceBuffer = ''
51-
private lastFlushTime = Date.now()
5251

5352
// Loading indicator state
54-
private loadingIndicatorTimer: NodeJS.Timeout | null = null
55-
private isShowingIndicator = false
56-
private indicatorStartTime = 0
57-
private indicatorFrame = 0
5853
private resizeHandler?: () => void
59-
// Three dots expanding and contracting animation
60-
private readonly indicatorFrames = [
61-
'···',
62-
'•··',
63-
'●•·',
64-
'●●•',
65-
'●●●',
66-
'●●•',
67-
'●•·',
68-
'•··',
69-
]
70-
private readonly indicatorThresholdMs = 50
71-
private readonly indicatorUpdateMs = 150
7254

7355
constructor(opts: MarkdownStreamRendererOptions = {}) {
7456
this.width = opts.width ?? (process.stdout.columns || 80)
7557
this.isTTY = opts.isTTY ?? process.stdout.isTTY
7658
this.syntaxHighlight = opts.syntaxHighlight ?? true
77-
this.maxBufferBytes = (opts.maxBufferKB ?? 64) * 1024
7859
this.streamingMode = opts.streamingMode ?? 'smart'
7960

8061
// Initialize markdown-it with terminal renderer
@@ -170,8 +151,7 @@ export class MarkdownStreamRenderer {
170151
// Check if we should force flush due to age or size
171152
this.checkForceFlush(outs)
172153

173-
// Update loading indicator
174-
this.updateLoadingIndicator()
154+
Spinner.get().start(null, true)
175155
}
176156

177157
private processLine(
@@ -341,7 +321,7 @@ export class MarkdownStreamRenderer {
341321
if (!this.currentBlock) return
342322

343323
// Hide loading indicator if showing
344-
this.hideLoadingIndicator()
324+
Spinner.get().stop()
345325

346326
// Render the block
347327
const rendered = this.render(this.currentBlock.buffer)
@@ -354,7 +334,6 @@ export class MarkdownStreamRenderer {
354334

355335
// Reset block state
356336
this.currentBlock = null
357-
this.lastFlushTime = Date.now()
358337
}
359338

360339
private checkForceFlush(outs: string[]) {
@@ -373,6 +352,9 @@ export class MarkdownStreamRenderer {
373352
(age > 1000 && this.currentBlock.type === 'list') // Lists get extra time to accumulate
374353

375354
if (shouldForceFlush) {
355+
// Hide indicator since we flushed something
356+
Spinner.get().stop()
357+
376358
// Try to find a soft boundary for force flush
377359
const buffer = this.currentBlock.buffer
378360
const sentenceEnd = buffer.lastIndexOf('. ')
@@ -387,71 +369,10 @@ export class MarkdownStreamRenderer {
387369
const rendered = this.render(toFlush)
388370
outs.push(rendered)
389371
this.consumedIndex += toFlush.length
390-
this.lastFlushTime = now
391-
392-
// Hide indicator since we flushed something
393-
this.hideLoadingIndicator()
394372
}
395373
}
396374
}
397375

398-
private updateLoadingIndicator() {
399-
if (!this.currentBlock || !this.isTTY) return
400-
401-
const now = Date.now()
402-
const age = now - this.currentBlock.startTime
403-
404-
// Show indicator if buffering for too long
405-
if (age > this.indicatorThresholdMs && !this.isShowingIndicator) {
406-
this.showLoadingIndicator()
407-
}
408-
}
409-
410-
private showLoadingIndicator() {
411-
if (this.isShowingIndicator || !this.isTTY) return
412-
413-
this.isShowingIndicator = true
414-
this.indicatorStartTime = Date.now()
415-
this.indicatorFrame = 0
416-
417-
// Write initial indicator
418-
this.writeIndicator()
419-
420-
// Start update timer
421-
this.loadingIndicatorTimer = setInterval(() => {
422-
this.indicatorFrame =
423-
(this.indicatorFrame + 1) % this.indicatorFrames.length
424-
this.writeIndicator()
425-
}, this.indicatorUpdateMs)
426-
}
427-
428-
private writeIndicator() {
429-
if (!this.isShowingIndicator) return
430-
431-
const dot = this.indicatorFrames[this.indicatorFrame]
432-
const message = gray(` ${dot} `)
433-
434-
// Write to stderr to avoid interfering with stdout
435-
process.stderr.write(`\r${message}`)
436-
}
437-
438-
private hideLoadingIndicator() {
439-
if (!this.isShowingIndicator) return
440-
441-
this.isShowingIndicator = false
442-
443-
// Clear timer first to prevent race conditions
444-
if (this.loadingIndicatorTimer) {
445-
clearInterval(this.loadingIndicatorTimer)
446-
this.loadingIndicatorTimer = null
447-
}
448-
449-
// Clear the indicator line completely and move cursor to beginning of line
450-
// Use enough spaces to clear the longest possible indicator + padding
451-
// Format is ' ●●● ' so we need at least 5 chars + some buffer
452-
process.stderr.write('\r' + ' '.repeat(20) + '\r')
453-
}
454-
455376
private processConservativeMode(text: string, outs: string[]) {
456377
// This is a simplified version of the original behavior
457378
// Just accumulate and flush on double newlines
@@ -472,7 +393,7 @@ export class MarkdownStreamRenderer {
472393

473394
end(): string | null {
474395
// Hide any loading indicator
475-
this.hideLoadingIndicator()
396+
Spinner.get().stop()
476397

477398
const outputs: string[] = []
478399

@@ -617,7 +538,7 @@ export class MarkdownStreamRenderer {
617538
})
618539
}
619540

620-
const bufferLine = `${bgGray}${padLeft}${' '.repeat(wrapWidth)}${padRight}${reset}`
541+
const bufferLine = `${bgGray}\`\`\`${padLeft}${' '.repeat(wrapWidth - 3)}${padRight}${reset}`
621542
const backgroundCode = [
622543
bufferLine,
623544
...wrappedLines,

npm-app/src/subagent-headers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function printSubagentHeader(event: PrintModeEvent) {
2222
)
2323
const dashes = '-'.repeat(dashesLength)
2424

25-
const shouldResumeSpinner = Spinner.get().stop()
25+
const stoppedSpinner = Spinner.get().stop()
2626
if (event.type === 'subagent_start') {
2727
console.log(`\n\n${dashes} ${fullAgentName} ${dashes}\n`)
2828
} else {
@@ -39,7 +39,7 @@ export function printSubagentHeader(event: PrintModeEvent) {
3939
)
4040
console.log(``)
4141
}
42-
if (shouldResumeSpinner) {
43-
return Spinner.get().start(null)
42+
if (stoppedSpinner && stoppedSpinner.type === 'text') {
43+
return Spinner.get().start(stoppedSpinner.text)
4444
}
4545
}

npm-app/src/utils/spinner.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import * as readline from 'readline'
22

3-
import { green } from 'picocolors'
3+
import { gray, green } from 'picocolors'
44

55
import { createTimeoutDetector } from './rage-detector'
66
import { HIDE_CURSOR_ALT, SHOW_CURSOR_ALT } from './terminal'
77
import { getPrevious, setPrevious } from '../display/squash-newlines'
88

9-
const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
9+
const textFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
10+
const textlessFrames = ['···', '•··', '●•·', '●●•', '●●●', '●●•', '●•·', '•··']
11+
function getFrame(
12+
textless: boolean,
13+
frameNumber: number,
14+
text: string,
15+
): string {
16+
const frames = textless ? textlessFrames : textFrames
17+
const index = frameNumber % frames.length
18+
const frame = frames[index]
19+
if (textless) {
20+
return gray(` ${frame} `)
21+
}
22+
return green(`${frame} ${text}`)
23+
}
1024

1125
export class Spinner {
1226
private static instance: Spinner | null = null
@@ -17,6 +31,7 @@ export class Spinner {
1731
})
1832
private previous: string | null = null
1933
private text: string = 'Thinking'
34+
private textless: boolean = false
2035

2136
private constructor() {}
2237

@@ -31,11 +46,13 @@ export class Spinner {
3146
* Start the spinner with the given text.
3247
*
3348
* @param text The text to display in the spinner. If this is `null`, the spinner will resume with the previous text.
49+
* @param textless Whether to use textless spinner frames.
3450
*/
35-
start(text: string | null): void {
51+
start(text: string | null, textless: boolean = false): void {
3652
if (text !== null) {
3753
this.text = text
3854
}
55+
this.textless = textless
3956
if (this.loadingInterval) {
4057
return
4158
}
@@ -49,8 +66,8 @@ export class Spinner {
4966
// Hide cursor while spinner is active
5067
process.stdout.write(HIDE_CURSOR_ALT)
5168
this.loadingInterval = setInterval(() => {
52-
this.rewriteLine(green(`${chars[i]} ${this.text}`))
53-
i = (i + 1) % chars.length
69+
this.rewriteLine(getFrame(this.textless, i, this.text))
70+
i++
5471
}, 100)
5572
}
5673

@@ -63,12 +80,12 @@ export class Spinner {
6380
*
6481
* @returns `true` if the spinner was active before calling this method, `false` otherwise.
6582
*/
66-
stop(): boolean {
83+
stop(): { type: 'text'; text: string } | { type: 'textless' } | null {
6784
// Clear hang detection
6885
this.hangDetector.stop()
6986

7087
if (!this.loadingInterval) {
71-
return false
88+
return null
7289
}
7390

7491
clearInterval(this.loadingInterval)
@@ -80,7 +97,9 @@ export class Spinner {
8097
setPrevious(this.previous)
8198
}
8299
this.previous = null
83-
return true
100+
return this.textless
101+
? { type: 'textless' }
102+
: { type: 'text', text: this.text }
84103
}
85104

86105
restoreCursor() {

npm-app/src/utils/xml-stream-parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { getPartialJsonDelta } from '@codebuff/common/util/partial-json-delta'
77
import { Saxy } from '@codebuff/common/util/saxy'
88

99
import { defaultToolCallRenderer } from './tool-renderers'
10+
import { MarkdownStreamRenderer } from '../display/markdown-renderer'
1011

1112
import type { ToolCallRenderer } from './tool-renderers'
12-
import { MarkdownStreamRenderer } from '../display/markdown-renderer'
1313

1414
// Track active renderer instances with reference counting
1515
let activeRendererCount = 0

0 commit comments

Comments
 (0)