@@ -51,6 +51,39 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
5151const CTRL_L_PATTERN = / \x0c / g;
5252const LEADING_WHITESPACE_PATTERN = / ^ [ \s \r \n ] + / ;
5353
54+ /**
55+ * Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
56+ * Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA, CSI n;m H = CUP)
57+ * to animate the spinner and update the status bar. During long thinking phases, these frames
58+ * accumulate to 500KB+ of repeated overwrites to the same rows. When the buffer is tailed,
59+ * only spinner frames are returned, making the terminal appear empty.
60+ *
61+ * Strategy: find where absolute-positioned redraws begin (first VPA sequence), then keep
62+ * only the last ~4KB of redraw frames (the final visual state) and discard the rest.
63+ */
64+ function stripInkRedrawBloat ( buffer : string ) : string {
65+ // Find where Ink's absolute-positioned redraws start (first CSI n d = VPA)
66+ // eslint-disable-next-line no-control-regex
67+ const firstVPA = buffer . search ( / \x1b \[ \d + d / ) ;
68+ if ( firstVPA === - 1 ) return buffer ; // No Ink redraws
69+
70+ const contentPart = buffer . slice ( 0 , firstVPA ) ;
71+ const redrawPart = buffer . slice ( firstVPA ) ;
72+
73+ // If the redraw section is small (<16KB), not worth stripping
74+ if ( redrawPart . length < 16384 ) return buffer ;
75+
76+ // Keep only the last 4KB of redraw frames — this preserves the final visual state
77+ // (spinner position, status bar text, token count, etc.)
78+ const tail = redrawPart . slice ( - 4096 ) ;
79+ // Avoid starting mid-escape: find first complete frame boundary
80+ // eslint-disable-next-line no-control-regex
81+ const frameStart = tail . search ( / \x1b \( B \x1b \[ m | \x1b \[ \d + d | \x1b \[ \d + ; \d + H / ) ;
82+ const cleanTail = frameStart > 0 ? tail . slice ( frameStart ) : tail ;
83+
84+ return contentPart + cleanTail ;
85+ }
86+
5487export function registerSessionRoutes (
5588 app : FastifyInstance ,
5689 ctx : SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -534,10 +567,16 @@ export function registerSessionRoutes(
534567 let truncated = false ;
535568 let cleanBuffer : string ;
536569
537- if ( tailBytes > 0 && fullSize > tailBytes ) {
570+ // Strip redundant Ink spinner/status redraws BEFORE tailing.
571+ // During long thinking phases, Ink rewrites the same rows thousands of times
572+ // (500KB+). Without stripping, tail mode returns only spinner frames and
573+ // the terminal appears empty when switching tabs.
574+ const strippedBuffer = stripInkRedrawBloat ( session . terminalBuffer ) ;
575+
576+ if ( tailBytes > 0 && strippedBuffer . length > tailBytes ) {
538577 // Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
539578 // Banner is near the top and gets discarded by tail anyway.
540- cleanBuffer = session . terminalBuffer . slice ( - tailBytes ) ;
579+ cleanBuffer = strippedBuffer . slice ( - tailBytes ) ;
541580 truncated = true ;
542581 // Avoid starting mid-ANSI-escape: find first newline within the first 4KB
543582 // and start from there. This prevents xterm.js from parsing a partial escape
@@ -548,7 +587,7 @@ export function registerSessionRoutes(
548587 }
549588 } else {
550589 // Full buffer: clean junk before actual Claude content
551- cleanBuffer = session . terminalBuffer ;
590+ cleanBuffer = strippedBuffer ;
552591
553592 // Find where Claude banner starts (has color codes before "Claude")
554593 const claudeMatch = cleanBuffer . match ( CLAUDE_BANNER_PATTERN ) ;
0 commit comments