Skip to content

Commit d436c63

Browse files
Ark0Nclaude
andcommitted
fix: strip Ink spinner bloat from terminal buffer before tailing
During long thinking phases, Ink's TUI rewrites the spinner/status bar thousands of times via absolute cursor positioning (VPA/CUP). These 500KB+ of redraw frames pushed real content out of the 128KB tail window, making the terminal appear empty when switching tabs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e96baf9 commit d436c63

1 file changed

Lines changed: 42 additions & 3 deletions

File tree

src/web/routes/session-routes.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,39 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
5151
const CTRL_L_PATTERN = /\x0c/g;
5252
const 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+
5487
export 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

Comments
 (0)