
Tui Troubleshooting
- 43 installs
- 50 repo stars
- Updated June 18, 2026
- josiahsiegel/claude-plugin-marketplace
Helps with ai & agent building tasks.
About
tui-troubleshooting is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- tui-troubleshooting
- AI & Agent Building
- AI-coding skill
Tui Troubleshooting by the numbers
- 43 all-time installs (skills.sh)
- +5 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #7,972 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill tui-troubleshootingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 43 |
|---|---|
| repo stars | ★ 50 |
| Last updated | June 18, 2026 |
| Repository | josiahsiegel/claude-plugin-marketplace ↗ |
What it does
Helps with ai & agent building tasks.
Files
TUI Troubleshooting: Freezes, Hangs, Blank Screens, and Terminal State Corruption
Use this skill when the user reports that a TUI "froze", "hung", "blanked the terminal", "stopped accepting input", "left the terminal broken", or "works in one terminal but not another". Treat these reports as terminal lifecycle failures until proven otherwise.
First response rule
Do not give a shallow checklist. Establish the failure class, inspect startup and cleanup order, and look for a concrete lifecycle bug:
1. Does the app enter raw mode, alternate screen, mouse, focus, or bracketed paste? 2. Is every mode restored on normal exit, Ctrl-C, exception, panic, rejected promise, and early return? 3. Is stdin flowing after terminal setup? 4. Does the app render and flush at least one frame before waiting on async work or input? 5. Can logs, stderr, background tasks, or child processes write into the same terminal surface? 6. Does the bug reproduce only in tmux, SSH, Windows/ConPTY, CI, or a non-TTY pipe?
High-probability freeze causes
Prioritize these before exotic terminal protocol issues:
- Raw mode without restore. Echo is disabled and line editing is gone, making the shell look frozen after a crash or early return.
- Paused stdin never resumed. In Node,
process.stdin.pause()followed by raw mode withoutresume()preventsdataevents; the UI may wait forever. - Missing first render. The app enters alternate screen/raw mode, starts an async status check, and never flushes an initial frame, so users see a blank screen.
- Blocking startup work. Synchronous file/network/process calls run before the first draw and starve the event loop or render loop.
- Input mode mismatch. The code waits for line-oriented input while stdin is raw, or waits for raw key events while stdin is cooked or paused.
- Cleanup mode leak. Alternate screen, mouse reporting, focus events, or bracketed paste remain enabled after exit.
Minimal diagnostic posture
Ask for or inspect:
- Language/framework and terminal library.
- Startup sequence from process start through first render.
- Cleanup/guard/finally/defer/Drop/atexit/signal handlers.
- Whether stdin/stdout/stderr are TTYs.
- Whether failure occurs locally, over SSH, in tmux/screen/Zellij, on Windows, or in CI.
- Raw terminal symptoms: echo off, cursor hidden, blank alternate screen, paste wrappers, mouse clicks swallowed, garbled sequences.
Recovery commands for users
When a terminal is stuck, tell the user how to recover before deeper debugging:
stty sane
reset
printf '\033[?1049l\033[?25h\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?1004l\033[0m'On Windows PowerShell, closing the tab is sometimes the fastest recovery when a child process left ConPTY in a bad state.
Fix pattern to prefer
Every TUI should use one lifecycle owner:
1. Verify TTY or choose non-TUI mode. 2. Install cleanup guards first. 3. Enter alternate screen/raw mode and optional mouse/paste/focus modes. 4. Resume/ref stdin if raw key events are needed. 5. Render and flush an initial frame immediately. 6. Start async work after the first frame. 7. Route logs away from the controlled terminal surface. 8. Restore modes in reverse order exactly once.
Reference files
references/freeze-hang-diagnosis.md- Root causes, symptoms, diagnostics, fix sketches, and prevention patterns for startup and runtime hangs.references/terminal-state-corruption.md- Garbled display, wrong colors, cursor bugs, partial renders, interleaved writes, and resize/render races.references/diagnostic-playbook.md- Decision trees with concrete commands for freezes, stuck terminal state, no input, SSH/tmux, Windows, and garbled output.
Diagnostic Playbook
Use these workflows when a user reports freezing, hanging, blank screens, no input, corrupted output, or platform-specific terminal behavior. Prefer file logs or PTY captures over printing to stdout/stderr while the TUI is active.
Setup: collect environment facts
Ask for or log:
printf 'TERM=%s\nCOLORTERM=%s\nTMUX=%s\nSTY=%s\nZELLIJ=%s\nSSH_TTY=%s\n' "$TERM" "$COLORTERM" "$TMUX" "$STY" "$ZELLIJ" "$SSH_TTY"
stty -a
stty size
tput colorsNode-specific:
log({ stdinTTY: process.stdin.isTTY, stdoutTTY: process.stdout.isTTY, stderrTTY: process.stderr.isTTY, term: process.env.TERM });PowerShell/Windows:
$PSVersionTable
$env:TERM
$env:WT_SESSION
$env:ConEmuANSI
[Console]::InputEncoding
[Console]::OutputEncoding1. "My TUI freezes on startup" decision tree
1. Does the process still run?
- Unix:
ps -o pid,stat,pcpu,pmem,command -p <pid> - Windows:
Get-Process -Id <pid> | Format-List Id,CPU,Responding,Path - If not running and terminal is broken, jump to stuck-after-exit.
2. Did it enter alternate screen before drawing?
- Capture raw output with a PTY recorder or add a file log before/after terminal entry, first render, and flush.
- Look for
?1049hwith no visible frame after it. - Fix: render and flush a loading frame before async checks.
3. Is stdin paused or not flowing?
- Node: grep for
process.stdin.pause(),setRawMode,resume,ref,unref,readline. - If
pause()occurs before raw mode and no laterresume(), fix startup ordering.
4. Is startup blocking before first draw?
- Search for sync calls:
readFileSync,execSync,spawnSync, large JSON parsing, blocking network/database calls. - Add timestamps to a file:
start,enter terminal,first render,after status check. - Fix: draw first, then run work asynchronously.
5. Is the event loop spinning?
- High CPU: suspect render/update loop. Count render calls per second.
- Low CPU: suspect awaiting input, paused stdin, deadlock, or blocked child process.
6. Minimum expected fix.
- Install cleanup guard.
- Enter terminal modes.
- Resume stdin if using raw input.
- Render and flush first frame.
- Start async work.
2. "My TUI freezes after an action" decision tree
1. Identify the action boundary. Log to a file before handler, after handler, before async command, after command, before render, after flush.
2. Does CPU spike?
- Yes: render loop or expensive computation. Count renders and inspect state mutation during render.
- No: blocked I/O, child command, channel/promise wait, or input ownership bug.
3. Did the action spawn a child process?
- Check whether child stdio is inherited.
- If the child is interactive, suspend the TUI or run it outside alternate screen.
- If noninteractive, pipe output into the model.
4. Did the action change input mode?
- Check raw/cooked transitions, readline creation, prompt libraries, and focus changes.
- Ensure the same component restores input ownership.
5. Did the action resize, open a modal, or change layout?
- Check for out-of-bounds coordinates and stale cached dimensions.
- Recompute layout from current size.
6. Add a timeout. Any async operation triggered from UI should have timeout/cancel behavior and surface an error frame.
3. "My terminal is stuck after my TUI exits" decision tree
1. Recover first.
stty sane
reset
printf '\033[0m\033[?25h\033[r\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?1004l'2. Classify the stuck mode.
- No echo / weird Enter: raw/cbreak/noecho leak.
- Blank screen/no scrollback: alternate screen leak.
- Cursor missing: cursor hide leak.
- Clicks weird: mouse reporting leak.
- Paste markers: bracketed paste leak.
- Escape sequences on focus: focus reporting leak.
- Prompt color wrong: SGR reset leak.
3. Inspect cleanup coverage.
- Normal quit.
- Ctrl-C/SIGINT.
- SIGTERM.
- Exception/panic/rejected promise.
- Early return after failed initialization.
- Test failure.
4. Fix with idempotent reverse-order cleanup. Cleanup should be safe to call multiple times and should not throw.
5. Prove it. Add tests that intentionally crash after enabling each terminal mode and assert cleanup bytes or final terminal state.
4. "My TUI shows garbled output" decision tree
1. Are escape sequences visible?
- Visible
^[,[31m,?1049h: terminal does not support/parse the sequence, VT mode disabled, or writes are interleaved/incomplete. - Windows: verify VT processing or use a library that enables it.
2. Are logs mixed into the UI?
- Disable all stdout/stderr logging.
- Redirect logs to a file.
- Spawn children with piped stdio.
3. Are borders/columns misaligned?
- Test CJK, emoji, combining marks, and ambiguous-width characters.
- Replace Unicode borders/icons with ASCII mode.
- Use width/grapheme libraries.
4. Does it happen during resize?
- Log sizes for resize event, layout, render, flush.
- Coalesce resize events and recompute layout.
5. Does it happen over slow SSH?
- Reduce frame rate and flush once per frame.
- Use diff rendering instead of full clears.
5. "My TUI doesn't respond to input" decision tree
1. Check TTY and stream ownership.
- Node:
process.stdin.isTTY,setRawMode,resume, data listener installed. - Python: curses/prompt_toolkit/Textual owns input; avoid direct reads.
- Go/Rust: event polling loop active and not blocked.
2. Raw vs cooked mismatch.
- Raw key handler expects bytes/events: raw mode on, stdin flowing.
- Line prompt expects Enter-delimited lines: raw mode off or framework-managed.
3. Conflicting libraries.
- Do not mix readline with raw listeners unless the lifecycle is explicit.
- Do not run a prompt library inside a full-screen framework without suspending the framework.
4. Escape timing and Alt/Esc ambiguity.
- In SSH/tmux, Esc-based sequences can be delayed.
- Tune escape timeout or use framework key protocols when available.
5. Focus/mouse assumptions.
- Keyboard must work without mouse and without focus events.
- If mouse is required for a path, that is an accessibility and compatibility bug.
6. "My TUI works locally but breaks over SSH/tmux" decision tree
1. Compare environments.
echo "$TERM"
echo "$TMUX $STY $ZELLIJ $SSH_TTY"
tput colors
stty size
infocmp | head2. Check capability assumptions.
- Truecolor may need tmux overrides.
- Mouse/focus/clipboard protocols may be filtered.
- Alternate screen can be inhibited by configuration.
- Kitty/iTerm2 extensions rarely pass through all remotes.
3. Check resize propagation.
- Resize the outer terminal and log the app's received dimensions.
- If missing, verify SIGWINCH/event handling and multiplexer settings.
4. Check latency behavior.
- Avoid rendering faster than data changes.
- Coalesce input and redraw events.
- Reduce animation/spinner frame rates.
5. Fallback. Use plain ANSI/16-color/no-mouse mode when uncertain, and provide --no-mouse, --color=auto|never, --ascii, or --plain switches.
7. "My TUI works on Unix but breaks on Windows" decision tree
1. Identify the host. Windows Terminal, VS Code terminal, classic conhost, ConEmu, Git Bash, WSL, or a ConPTY test harness.
2. Are ANSI sequences printed literally?
- VT processing may not be enabled.
- Use Crossterm, tcell, Terminal.Gui, Spectre.Console, prompt_toolkit, or another library that handles Windows console setup.
3. Does output appear late or tests hang?
- ConPTY buffering differs from Unix PTYs.
- Flush after frames.
- Avoid assertions that depend on exact byte chunking.
4. Do keys differ?
- Ctrl/Alt/function key encodings and terminal host behavior vary.
- Test keybindings in Windows Terminal and the advertised host.
5. Does cleanup fail?
- Signal semantics differ;
SIGTERM/Ctrl-C handling may not match Unix. - Ensure cleanup runs on process exit, Ctrl-C, exceptions, and framework shutdown hooks.
6. Path and encoding checks.
- Use UTF-8 internally.
- Avoid assuming Unix paths in snapshot tests.
- Normalize CRLF where output comparisons are line-oriented.
Evidence to include in bug reports
- Exact command and terminal host.
- OS, shell,
$TERM, multiplexer/SSH status. - Whether stdin/stdout/stderr are TTYs.
- First-frame timing: terminal entry, first render, first flush, first async operation.
- Cleanup modes enabled and disabled.
- Raw output capture or final virtual terminal snapshot.
- Minimal key sequence or action that triggers the freeze.
Freeze and Hang Diagnosis
A "frozen terminal" is often not a single bug. It can mean the process is alive but not rendering, input is disabled, stdin is paused, the terminal is in raw mode after exit, or alternate screen cleanup failed. Start by separating process hang, render hang, input hang, and terminal-state leak.
Fast triage
- Does Ctrl-C terminate the process? If yes, inspect cleanup and signal handling. If no, suspect event-loop blockage, native deadlock, or child process wait.
- Does typing appear? If no, echo may be off due to raw mode or the app is still running in alternate screen.
- Does CPU spike? If yes, suspect infinite render/update loop. If no, suspect blocked I/O, awaiting input, paused stdin, or async deadlock.
- Is the screen blank immediately? Suspect alternate screen entered before first flushed render.
- Is the shell broken after exit? Suspect raw mode, cursor, mouse, bracketed paste, focus, color, or alternate-screen cleanup leak.
Raw mode without restore
Symptom. After a crash or Ctrl-C, typed characters do not echo, Enter does not behave normally, Ctrl-C may print ^C oddly, and the user thinks the terminal froze.
Root cause. Raw mode disables canonical input processing and echo. If the program exits before setRawMode(false), disable_raw_mode(), noraw(), endwin(), or equivalent cleanup, the shell inherits a hostile terminal state.
Diagnostics. Run stty -a in another shell attached to the same terminal if possible, or recover with stty sane. Inspect all early returns, exceptions, promise rejections, panics, and signal paths after raw mode is enabled.
Fix pattern. Install cleanup before enabling raw mode and make it idempotent.
let cleaned = false;
function cleanup() {
if (cleaned) return;
cleaned = true;
if (process.stdin.isTTY) process.stdin.setRawMode(false);
process.stdout.write('\x1b[?25h\x1b[?1049l\x1b[0m');
}
process.once('exit', cleanup);
process.once('SIGINT', () => { cleanup(); process.exit(130); });
process.once('uncaughtException', err => { cleanup(); console.error(err); process.exit(1); });
process.once('unhandledRejection', err => { cleanup(); console.error(err); process.exit(1); });Prevention. Use framework lifecycle guards, try/finally, Rust RAII guards, Go defer, Python curses.wrapper, or a single terminal-session object that restores modes in reverse order.
Stdin paused/resumed mismatch
Symptom. The screen may draw once or remain blank, but no keypresses arrive. The process is alive and waiting. This is common in Node apps launched by npm run tui.
Root cause. process.stdin.pause() removes the stream from flowing mode. Enabling raw mode does not necessarily resume data events. If code pauses stdin before raw mode, then waits for key events without resume() or a readable loop, input never reaches the handler.
Diagnostics. Search for stdin.pause(), setRawMode, on('data'), readline, unref, and resume. Add temporary file logging around startup: "before raw", "after raw", "after resume", "data event". Do not log to the TUI screen.
Fix pattern. Configure stdin in one place and resume before waiting.
if (!process.stdin.isTTY) throw new Error('TUI requires a TTY');
process.stdin.setEncoding('utf8');
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.ref();
process.stdin.on('data', key => dispatchKey(key));
renderInitialFrame();Prevention. Avoid mixing readline, raw data listeners, and framework input managers. If a status check temporarily stops input, explicitly restore the prior stream state.
Blocking I/O on the main thread
Symptom. The app appears frozen on startup or after an action; no spinner advances; resize and input are ignored. CPU may be low if blocked on disk/network, or high if doing sync computation.
Root cause. Synchronous file reads, child process waits, DNS/network calls, JSON parsing, compression, database calls, or CPU-heavy layout block the UI thread before the render loop can flush.
Diagnostics. Add timestamped file logs before and after suspicious calls. In Node, inspect readFileSync, execFileSync, spawnSync, sync globbing, and large JSON parsing. In Python, inspect blocking calls inside Textual/curses callbacks. In Go/Rust, inspect locks and synchronous commands on the UI goroutine/thread.
Fix pattern. Draw first, then move work off the UI path.
enterTerminal();
render({status: 'Checking project...'});
await flushOutput();
void checkProjectAsync().then(result => {
update({result});
scheduleRender();
});Prevention. Treat first paint as a service-level objective. Put long work in workers, tasks, goroutines, commands, or async jobs that publish messages back to the UI.
Deadlock in the event loop
Symptom. The UI waits forever after a particular action. CPU is usually idle. Logs show operation A waiting for operation B while B waits for A, or a render callback that never fires.
Root cause. Two async operations wait on each other, a promise is never resolved, a channel is never closed, or a lock is held while scheduling a callback that needs the same lock.
Diagnostics. Add timeouts around awaited operations. Dump pending promises/tasks where your runtime supports it. In Go, inspect goroutine dumps. In Rust, use tracing spans around async channels and locks.
Fix pattern. Avoid awaiting UI messages from inside a handler that owns the UI loop. Use one-way messages and timeout/cancellation.
select {
case msg := <-resultCh:
return model.WithResult(msg), nil
case <-time.After(10 * time.Second):
return model.WithError("operation timed out"), nil
}Prevention. Keep update/reducer functions nonblocking. Never hold locks while sending UI messages or invoking render callbacks.
Infinite render loop
Symptom. CPU spikes, fans spin, terminal may appear blank or flicker, and input is delayed. The process is alive but not making progress.
Root cause. Rendering mutates state, state mutation schedules render, and render mutates state again. Timers may schedule new ticks without coalescing.
Diagnostics. Count frames per second and state updates. Log render reasons to a file. If render counts climb without input or data changes, find state writes during view/layout/render.
Fix pattern. Make render pure and coalesce redraws.
let renderScheduled = false;
function scheduleRender() {
if (renderScheduled) return;
renderScheduled = true;
setImmediate(() => {
renderScheduled = false;
draw(view(model));
});
}Prevention. Separate model, update, and view. Use immutable view input or lint rules/tests that forbid state mutation during render.
Missing first render
Symptom. The terminal switches to a blank alternate screen and seems frozen until an async operation completes, or forever if that operation hangs.
Root cause. The app enters raw mode and alternate screen, then starts a status check or awaits input before writing and flushing an initial frame.
Diagnostics. Capture raw output. If you see CSI ? 1049 h and maybe cursor-hide sequences but no clear/draw/frame bytes afterward, first render is missing. Add file logs around enter, render, and flush.
Fix pattern. Render a loading frame synchronously immediately after terminal setup.
enterAlternateScreen();
enableRawMode();
process.stdin.resume();
render({screen: 'startup', message: 'Loading...'});
process.stdout.write(''); // ensure writes queued
await new Promise(resolve => process.stdout.write('', resolve));
startAsyncChecks();Prevention. Add a PTY test that asserts visible content appears within a short timeout after startup.
Alternate screen without cleanup
Symptom. After the app exits, the shell scrollback is gone or the terminal remains blank until reset or a new tab.
Root cause. The app sent CSI ? 1049 h but did not send CSI ? 1049 l on every exit path.
Diagnostics. Capture output and look for enter without matching leave. Reproduce by forcing an exception immediately after terminal entry.
Fix pattern. Put alternate-screen leave in the same idempotent cleanup guard as raw-mode restore.
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::cursor::Show);
}
}Prevention. Test panic/exception paths. Never scatter enter/leave calls across unrelated modules.
Mouse mode stuck on
Symptom. Mouse clicks insert escape sequences, select text poorly, or are swallowed by the terminal after the app exits.
Root cause. Mouse reporting was enabled (?1000, ?1002, ?1003, ?1006) and not disabled.
Diagnostics. Click in the shell and look for sequences like \x1b[<...M. Inspect cleanup for all enabled mouse modes.
Fix pattern. Disable every mouse mode you enable.
printf '\033[?1000l\033[?1002l\033[?1003l\033[?1006l'Prevention. Centralize terminal feature toggles and store which modes were enabled.
Bracketed paste not restored
Symptom. Pasted text appears wrapped in odd markers such as ^[[200~ and ^[[201~.
Root cause. Bracketed paste was enabled with CSI ? 2004 h and not disabled.
Diagnostics. Paste into the shell after app exit. Look for paste markers. Capture final output for missing CSI ? 2004 l.
Fix pattern. Always send \x1b[?2004l during cleanup.
Prevention. Enable bracketed paste only when an input widget benefits from it, and test exit cleanup.
Focus event mode stuck
Symptom. Switching terminal tabs or focus sends visible escape sequences or triggers unexpected input.
Root cause. Focus reporting (CSI ? 1004 h) remains enabled.
Diagnostics. Focus/unfocus the terminal after exit and watch for CSI I / CSI O behavior.
Fix pattern. Send \x1b[?1004l on cleanup.
Prevention. Treat focus reporting as optional progressive enhancement, not default startup behavior.
Waiting for input that never comes
Symptom. The UI says "press any key" or waits after startup, but input does nothing. Sometimes Enter works while arrow keys do not, or arrow keys work while line input does not.
Root cause. Code switched stdin to raw mode but waits for cooked line input, or left cooked mode while expecting raw key bytes. Framework input managers can also conflict with direct stdin readers.
Diagnostics. Inspect whether readline, curses, prompt_toolkit, Ink, Blessed, Bubble Tea, or another framework already owns input. Log raw byte events to a file.
Fix pattern. Pick one input abstraction. For raw mode, parse bytes/events. For line mode, do not enable raw mode.
Prevention. Document ownership of stdin and prohibit direct stdin access outside the input module.
TTY detection failure
Symptom. The TUI hangs in CI or when input/output is piped, or refuses to run inside an environment that actually has a usable TTY.
Root cause. Code assumes stdin/stdout are TTYs when they are not, or uses the wrong stream for capability checks.
Diagnostics. Print or log stdin.isTTY, stdout.isTTY, TERM, CI, and NO_COLOR. Test cmd | app, app > file, and app < file.
Fix pattern. Gate TUI mode and provide plain output.
const interactive = process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb';
if (!interactive) return runPlainMode();
return runTuiMode();Prevention. CI tests should cover non-TTY stdin and stdout separately.
Node.js-specific failures
Symptom. npm run tui blanks the terminal, never handles keys, exits too soon, or leaves raw mode stuck.
Root cause. Common causes include setRawMode(true) without exit cleanup, stdin.pause() before raw mode without resume(), readline competing with raw listeners, or stdin.unref() allowing premature process exit.
Diagnostics. Search for setRawMode, pause, resume, ref, unref, readline.createInterface, process.exit, SIGINT, uncaughtException, and unhandledRejection. Add file logs rather than console.log.
Fix pattern. One owner for stdin, cleanup before raw mode, resume stdin, render before async checks, and avoid process.exit until cleanup has run.
async function main() {
const session = createTerminalSession();
try {
session.enter(); // installs cleanup, raw, alt, stdin.resume()
session.renderLoading();
await session.flush();
await startApp();
} finally {
session.leave();
}
}Prevention. Add a node-pty startup test that expects a nonblank frame and accepts q within one second.
Python-specific failures
Symptom. curses apps leave the terminal broken after exceptions, fail to repaint, or Textual apps hang when mixed with custom asyncio loops.
Root cause. curses.initscr() without curses.endwin() in exception paths, curses.raw() without curses.noraw(), bypassing curses.wrapper, blocking work in the event loop, or not using Textual's run API correctly.
Diagnostics. Search for direct initscr, raw, cbreak, noecho, and custom event-loop code. Force an exception after initialization.
Fix pattern. Use safe wrappers and workers.
import curses
def app(stdscr):
curses.curs_set(0)
stdscr.addstr(0, 0, "Loading...")
stdscr.refresh()
# start nonblocking work or poll with timeout
curses.wrapper(app)Prevention. Keep blocking work out of Textual message handlers; use workers and call_from_thread/message posting as appropriate.
Rust-specific failures
Symptom. Ratatui/Crossterm app leaves raw mode or alternate screen on panic, or nothing draws after terminal setup.
Root cause. Terminal guard is dropped too early, panic occurs before cleanup, stdout is not flushed, or backend/session ownership is split.
Diagnostics. Search for enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, Drop, and panic::set_hook. Force a panic after entry.
Fix pattern. Use RAII plus panic hook when needed.
let mut terminal = setup_terminal()?;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&mut terminal)));
restore_terminal(&mut terminal)?;
if let Err(panic) = result { std::panic::resume_unwind(panic); }Prevention. Integration-test cleanup after a controlled panic in a PTY.
Go-specific failures
Symptom. Bubble Tea freezes during an external command or tcell apps do not restore the screen after panic.
Root cause. Blocking commands run in the update loop, tea.ExecCommand waits while the UI expects messages, goroutines deadlock on channels, or screen.Fini() is not deferred/recovered.
Diagnostics. Capture goroutine dumps. Inspect Bubble Tea commands and channel sends. Force panic after screen.Init().
Fix pattern. Use commands/goroutines that return messages, and defer cleanup.
screen, err := tcell.NewScreen()
if err != nil { return err }
if err := screen.Init(); err != nil { return err }
defer screen.Fini()
defer func() { if r := recover(); r != nil { screen.Fini(); panic(r) } }()Prevention. Keep Bubble Tea Update fast and return commands for long work.
SSH, tmux, and screen
Symptom. Works locally but hangs, misreads keys, ignores mouse, or fails to resize over SSH/tmux/screen/Zellij.
Root cause. Different TERM, terminfo, mouse support, focus support, truecolor behavior, alternate-screen policy, escape timing, or resize propagation.
Diagnostics. Log $TERM, $TMUX, $STY, $ZELLIJ, $SSH_TTY, tput colors, stty size, and resize events. Test outside the multiplexer and inside it.
Fix pattern. Feature-detect and gracefully degrade. Treat mouse/focus/truecolor as optional. Handle SIGWINCH/resize events.
Prevention. Include tmux and SSH scenarios in compatibility tests for any production TUI.
Windows-specific failures
Symptom. ANSI sequences print literally, input differs, output appears delayed, or tests pass on Unix but hang under Windows Terminal/ConPTY.
Root cause. Virtual terminal processing is disabled in classic console hosts, ConPTY buffers output differently, Ctrl-C handling differs, or Unix PTY assumptions leak into Windows code.
Diagnostics. Identify host: Windows Terminal, VS Code terminal, classic conhost, Git Bash, WSL, or ConPTY test harness. Check whether the library enables VT mode. Test through the claimed support path.
Fix pattern. Use cross-platform libraries that enable Windows VT and abstract ConPTY where possible. Flush after frame writes and avoid relying on Unix-only signal/PTY behavior.
Prevention. Run Windows integration tests if Windows support is advertised, not just Unix PTY tests.
Terminal State Corruption
Terminal state corruption is any failure where the process may still run, but the terminal display or interaction contract is damaged: garbled escape sequences, wrong colors, hidden cursor, incorrect cursor position, stuck scroll region, partial frames, mouse/paste/focus modes leaking, or debug output interleaving with the alternate screen.
Baseline recovery sequence
For Unix-like terminals:
stty sane
reset
printf '\033[0m\033[?25h\033[r\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?1004l'The sequence resets SGR attributes, shows the cursor, resets scroll region, leaves alternate screen, disables mouse reporting, disables bracketed paste, and disables focus reporting.
Incomplete escape sequences from interrupted writes
Symptom. Literal fragments like ^[, [38;2, ?1049h, or color codes appear on screen. The cursor may jump unexpectedly after the next write.
Root cause. The process writes an escape sequence in pieces and is interrupted, crashes, or another writer interleaves bytes between the pieces. Terminals parse byte streams, not logical write calls.
Diagnostics. Capture raw output bytes with a PTY recorder or script-like tool. Look for partial CSI/OSC/DCS sequences or interleaving between sequence bytes.
Fix pattern. Compose complete frames and control sequences into a buffer and flush once.
const frame = [];
frame.push('\x1b[?25l');
frame.push(renderCells(cells));
frame.push('\x1b[?25h');
process.stdout.write(frame.join(''));Prevention. Use a renderer/backend that batches writes. Avoid writing escape sequences from signal handlers or multiple modules.
Interleaved output from multiple threads or tasks
Symptom. Status logs appear inside bordered panels, frames contain half of two different renders, or escape sequences print literally.
Root cause. Multiple threads, goroutines, async tasks, subprocesses, or logging frameworks write to stdout/stderr while the TUI owns the screen.
Diagnostics. Redirect logs to a file and see if the corruption disappears. Search for console.log, print, println!, fmt.Println, log, stderr, child process inherited stdio, and progress libraries.
Fix pattern. The UI renderer should be the only writer to the controlled terminal. Route diagnostics to a file or in-app log panel.
const log = fs.createWriteStream(path.join(os.tmpdir(), 'app-tui.log'), {flags: 'a'});
function debug(message) { log.write(`${new Date().toISOString()} ${message}\n`); }Prevention. During TUI mode, configure logging sinks before entering alternate screen. Spawn child processes with stdio: 'pipe' or suspend/restore the TUI around interactive children.
Scroll region not reset
Symptom. Shell output after exit scrolls only within part of the terminal, or the prompt appears trapped in a pane-like region.
Root cause. The app used DECSTBM (CSI top ; bottom r) to set a scroll region and did not reset it with CSI r.
Diagnostics. Inspect raw output for r scroll-region sequences. After exit, run commands with many lines and see whether only a subsection scrolls.
Fix pattern. Include \x1b[r in cleanup and before full-screen layout changes that assume the whole screen.
Prevention. Prefer framework viewport widgets that own scroll regions and reset them. Add cleanup assertions in virtual terminal tests.
Cursor position not restored
Symptom. The prompt appears in the middle of the screen, later output overwrites UI remnants, or cursor is hidden after exit.
Root cause. Cursor hide/show, save/restore, alternate-screen, or manual cursor movement sequences are unbalanced.
Diagnostics. Look for ?25l without ?25h, save without restore, or crash paths after cursor hide.
Fix pattern. Cleanup must always show the cursor and place it at a safe position, usually after leaving alternate screen.
printf '\033[?25h\033[0m\n'Prevention. Hide cursor only during drawing, or manage it via a terminal session guard.
Color attributes not reset
Symptom. The shell prompt or later command output remains red, dim, bold, inverse, underlined, or has a background color.
Root cause. The app set SGR attributes and did not send SGR 0 (CSI 0 m) on frame boundaries and cleanup.
Diagnostics. Inspect final output bytes for missing \x1b[0m. Reproduce with a forced exception after rendering colored text.
Fix pattern. End every frame and cleanup path with reset.
queue!(stdout, crossterm::style::ResetColor, crossterm::style::SetAttribute(Attribute::Reset))?;Prevention. Use style scopes or cell renderers that emit resets when attributes change and at frame end.
Unicode width mismatch causing misalignment
Symptom. Borders break, columns drift, cursor lands inside characters, emoji overwrite neighboring cells, or CJK text shifts layout.
Root cause. Code measures bytes, code points, or grapheme count instead of display cell width. Terminals also differ on emoji and ambiguous East Asian width.
Diagnostics. Test strings with combining marks, emoji ZWJ sequences, CJK, and ambiguous-width characters. Compare expected cell grid to actual terminal output.
Fix pattern. Use maintained width/grapheme libraries and truncate by cell width, not string length.
# Pseudocode: iterate grapheme clusters and accumulate wcwidth cells.
for cluster in graphemes(text):
w = display_width(cluster)
if cells + w > limit: break
out.append(cluster)Prevention. Snapshot with fixed Unicode policy. Provide ASCII mode for limited fonts and remote environments.
Race conditions between resize and render
Symptom. Partial frames, panics from out-of-bounds coordinates, stale panels after resizing, or a blank screen until another keypress.
Root cause. Resize updates terminal dimensions while a render is using old dimensions, or SIGWINCH/resize events are handled outside the UI loop.
Diagnostics. Log terminal size at event receipt, layout calculation, and render flush. Stress-test rapid resizing in a PTY or manually.
Fix pattern. Treat resize as a normal UI event. Coalesce resize/render and recompute layout from current dimensions.
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nilPrevention. Do not cache absolute layout coordinates across frames unless they are invalidated by resize.
Background process writing to the same terminal
Symptom. A compiler, test runner, shell command, or subprocess output appears through the UI and corrupts frames.
Root cause. Child process inherits stdout/stderr while the TUI remains active.
Diagnostics. Inspect process spawn options. Temporarily redirect child stdio and compare behavior.
Fix pattern. Pipe child output into the app model, or suspend the TUI before handing control to an interactive child.
const child = spawn(cmd, args, {stdio: ['ignore', 'pipe', 'pipe']});
child.stdout.on('data', chunk => appendLog(chunk.toString()));Prevention. Make child-process stdio policy explicit in the TUI architecture.
Debug print statements corrupting alternate screen
Symptom. console.log/print debugging appears at random positions, breaks borders, or forces scrolling inside the alternate screen.
Root cause. Line-oriented debugging writes to the same terminal surface as the full-screen renderer.
Diagnostics. Grep for print/log statements and disable them. Confirm corruption disappears.
Fix pattern. Use file logging, structured in-app logs, or a debug overlay rendered by the UI.
Prevention. Provide a debug logger from day one and forbid direct stdout/stderr writes during TUI mode.
Partial renders and flicker
Symptom. Users see frames being drawn top-to-bottom, flickering borders, or old text remains after shorter new text.
Root cause. The app writes fragments without clearing damaged cells, does not diff frames, forgets to pad shortened lines, or flushes many times per frame.
Diagnostics. Slow output with a PTY proxy or run over SSH. Capture frames and inspect whether stale cells are cleared.
Fix pattern. Render to an offscreen buffer, diff against the previous buffer, clear/pad changed regions, and flush once.
Prevention. Avoid ad hoc move cursor + print code for complex screens; use a cell-buffer renderer or mature framework.