Terminal rendering: screen corruption on exit, 100% write ratio, input latency during streaming

Resolved 💬 2 comments Opened Apr 1, 2026 by kolkov Closed May 8, 2026

Terminal rendering: screen corruption on exit, 100% write ratio, input latency during streaming

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported as a consolidated analysis
  • [x] This covers multiple related rendering bugs with a single architectural root cause
  • [x] Analysis based on v2.1.88 source (from source map) + v2.1.89 runtime testing

Disclaimer

This analysis is based on the leaked v2.1.88 TypeScript source and our debug logs. We may be misunderstanding some implementation choices — if any of these are intentional behaviors or already have fixes in progress, we'd welcome corrections. We're filing this because the issues significantly impact daily productivity.

What's Wrong

Three symptoms that share a root cause in the Ink rendering architecture:

1. Screen corruption after /exit + restart

After exiting and restarting Claude Code, the previous session's content remains visible. New UI renders on top of old content. Text overlaps text.

2. 100% full-screen rewrite on every frame

Our debug logs show blit=0, write=42633 (100.0% writes) — zero partial updates, every frame redraws the entire screen (42K+ characters). Over a session we logged 2,447 contamination events. This should be using differential (blit) updates for the ~5% of screen that actually changed.

3. Input field unresponsive during streaming

While the model streams a response, typing in the input field lags noticeably — cursor movement delayed, characters appear in bursts. Other terminal windows are completely unaffected — including other Claude Code sessions running in parallel. This is not CPU or system-wide load. It's isolated to the specific Claude Code process that is currently streaming. A second Claude Code session in another terminal tab remains perfectly responsive while the first one lags.

Root Cause Analysis

We traced these through the source. The findings below reference specific files and lines — we understand the code may have changed since v2.1.88.

Screen corruption: 4 overlapping EXIT_ALT_SCREEN paths

During shutdown, up to four code paths each send \x1b[?1049l (EXIT_ALT_SCREEN):

  1. gracefulShutdown.ts:89inst.unmount()ink.tsx:1484: writeSync(1, EXIT_ALT_SCREEN)
  2. ink.tsx:222: signal-exit handler calls this.unmount (guarded by isUnmounted via detachForShutdown)
  3. AlternateScreen.tsx:55: useInsertionEffect cleanup calls writeRaw(EXIT_ALT_SCREEN)async stdout.write()
  4. ink.tsx:1484: direct writeSync in unmount body

Path 3 is the problem: React tree teardown (reconciler.updateContainerSync(null, ...) at ink.tsx:1517) fires AlternateScreen's cleanup, which calls writeRaw() (async). By this point, path 1 already exited alt screen via sync writeSync. The async write hits the main screen buffer, triggering DECRC cursor restore — clobbering the resume hint and shell prompt.

The developers clearly know about this — ink.tsx:927-930 comments document the exact problem, and detachForShutdown() (line 932) was added as a fix. But it only guards path 2 (signal-exit). Path 3 (AlternateScreen's useInsertionEffect cleanup) is unguarded — it doesn't check isUnmounted.

Suggested fix: AlternateScreen's cleanup should check if the Ink instance is already unmounted before writing EXIT_ALT_SCREEN. Or: clear the writeRaw ref before React teardown so the cleanup becomes a no-op.

100% write ratio: prevFrameContaminated stays true

ink.tsx patches stderr (via patchStderr) to detect third-party writes. Any process.stderr.write() — from MCP servers, hooks, debug logging — sets prevFrameContaminated = true, which forces the next render to skip differential updates and redraw the entire screen.

In practice, MCP server lifecycle messages, LSP diagnostics, and hook outputs continuously write to stderr, keeping prevFrameContaminated perpetually true. Result: every frame is a full 42K+ character repaint instead of a ~2K differential update.

Suggested fix: Track whether the stderr output actually overlapped with the screen area. If stderr wrote to lines below the viewport (which is common for debug output), the frame isn't actually contaminated. Or: move MCP/hook output to a separate fd or channel that doesn't trigger contamination.

Input latency: React reconciliation blocks the event loop

REPL.tsx (5,005 lines) contains 68 useState hooks in a single component. During streaming, each SSE event triggers setState → React reconciliation → Ink render → stdout.write(). This is synchronous — stdin events (keystrokes) wait in the event loop queue behind render work.

The code uses useDeferredValue(messages) (REPL.tsx:1318) to defer message renders, but it's bypassed during streaming (approximately line 4506: the deferred path is skipped when showStreamingText || !isLoading). This is exactly when input responsiveness matters most.

Suggested fix: Keep useDeferredValue active during streaming — the stale-while-revalidate pattern is designed for exactly this case. Or: extract the input handler out of the React render cycle entirely, processing stdin in a dedicated callback that doesn't wait for reconciliation.

Architectural observation

These bugs share a pattern: React's reconciliation model was designed for browser environments where rendering, input, and painting run on separate threads (compositor, main thread, input thread). In a terminal environment there is no compositor — stdout.write() is the only output path, reconciliation runs synchronously on the main thread (updateContainerSync + flushSyncWork), and stdin events share the same event loop. We verified in the source: zero worker threads in the Ink renderer, no SharedArrayBuffer, no child_process parallelism. Everything — render, input, network — competes for the same single thread.

We're not suggesting a rewrite — but the rendering pipeline might benefit from a reactive signal-based approach (similar to Angular Signals or Preact Signals) instead of React's full reconciliation. Signals provide fine-grained reactivity — when a signal changes, only the specific subscribers update, not the entire component tree. For a terminal app:

React approach:
  SSE event → setState(messages) → reconcile 5,005-line REPL component
  → diff entire virtual tree → generate terminal output → stdout.write
  → stdin events queued behind all of this

Signal approach:
  SSE event → streamingText.set(chunk)
  → ONLY the streaming text region re-renders (direct terminal write)
  → input signal has its own subscription (never blocked by streaming)
  → no tree diffing, no reconciliation, no virtual DOM

This would eliminate the input latency problem structurally — input and output become independent signal chains that don't block each other. The 68 useState hooks become fine-grained signals that update only their subscribers.

Angular's signal implementation runs change detection only for affected components and their ancestors, not the entire tree. For a 5,005-line REPL component where 95% of the UI is static during streaming, this would reduce render work by ~20x.

We realize these are significant observations. The immediate fixes (guard AlternateScreen cleanup, reduce prevFrameContaminated scope, keep useDeferredValue during streaming) would address the symptoms without architectural changes.

However, architecturally the right solution is a complete rethinking of the rendering layer — this is work that requires real engineering investigation, not quick patches. The core issue — React reconciliation blocking stdin in a single-threaded terminal app — is structural.

As we proposed in #39755, the most robust path forward is rewriting the CLI in Go (or similar compiled language with goroutines). Go's concurrency model eliminates these problems by design: rendering, input handling, and network streaming run in independent goroutines that never block each other. Every serious CLI tool (Docker, Kubernetes, Terraform, GitHub CLI) is built this way for exactly these reasons. A Go terminal library like bubbletea provides all the TUI primitives without React's reconciliation overhead.

We understand this is a major engineering decision far beyond the scope of this issue. But the pattern of terminal rendering bugs (this issue), streaming reliability bugs (#39755, #33949), and the single-threaded architecture limitations suggests the current stack has reached its ceiling for a long-running interactive CLI tool.

Our broader research into Claude Code reliability: dev.to/kolkov

CC: @bcherny @ant-kurt @fvolcic @ashwin-ant @bogini @OctavianGuzu @hackyon-anthropic @chrislloyd @ThariqS @catherinewu @whyuan-cc @dhollman @rboyce-ant @dicksontsai @wolffiex @ddworken @km-anthropic

Ultimately, the terminal rendering architecture needs a fundamental rethink — this is a separate engineering effort that requires proper research. The current approach of React/Ink reconciliation in a single-threaded terminal environment creates an inherent tension between render throughput and input responsiveness that no amount of useDeferredValue or setTimeout(0) yield hacks can fully resolve.

Our recommendation — which we've detailed in our research — is to rewrite the CLI in Go, where goroutines provide true parallelism: render in one goroutine, handle input in another, manage network in a third. Input latency becomes structurally impossible. Terminal cleanup is a defer statement. No reconciliation, no virtual DOM, no 68 useState hooks in a 5,005-line component. We run a Go-based multi-LLM tool (PupSeek) on the same machine with zero input lag during streaming — the difference is night and day.

Environment

  • Claude Code: v2.1.89 (npm/Node, patched watchdog)
  • OS: Windows 10, Git Bash terminal in GoLand IDE
  • Terminal: also tested in Windows Terminal, same symptoms
  • Debug log evidence: 2,447 contamination events, blit=0 write=100% consistently
  • Analysis: source from v2.1.88 source map

Evidence from debug logs

2026-04-01T14:43:37.628Z [DEBUG] High write ratio: blit=0, write=42633 (100.0% writes), screen=518x226
2026-04-01T14:45:15.496Z [DEBUG] High write ratio: blit=0, write=42685 (100.0% writes), screen=514x226
2026-04-01T14:45:32.328Z [DEBUG] High write ratio: blit=0, write=43156 (100.0% writes), screen=517x226

Zero blit (differential) updates across the entire session. Every frame = full screen repaint.

Related

  • #33949 — streaming hang root cause (our analysis)
  • #39755 — watchdog fallback dead code (our analysis)
  • #5024 — storage architecture audit
  • Our full research: dev.to/kolkov
  • Detailed rendering bug analysis: based on src/ink/ink.tsx, src/ink/components/AlternateScreen.tsx, src/screens/REPL.tsx, src/utils/gracefulShutdown.ts

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗