Skip to content

Fix garbled Claude Code TUI in cmux ssh remote workspaces (#6352) - #6831

Merged
austinywang merged 3 commits into
mainfrom
issue-6352-claude-code-tui-renders-garbled-output
Jun 29, 2026
Merged

austinywang merged 3 commits into
mainfrom
issue-6352-claude-code-tui-renders-garbled-output

Conversation

@austinywang

@austinywang austinywang commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6352

Problem

Running the Claude Code CLI (claude) — or any full-screen TUI — inside a cmux ssh remote workspace renders garbled output (stray escape sequences / scrambled frame). Running claude locally is fine, so the bug is specific to the SSH remote path.

Root cause

The remote shell bootstrap (RemoteInteractiveShellBootstrapBuilder.terminalSetupLines, duplicated in CLI/cmux.swift's interactiveRemoteTerminalSetupLines) decided TERM synchronously but installed the bundled xterm-ghostty terminfo in a background job (tic -x - … &):

cmux_term='xterm-256color'
if infocmp xterm-ghostty …; then cmux_term='xterm-ghostty'; fi
export TERM="$cmux_term"
if [ "$cmux_term" != 'xterm-ghostty' ]; then
  ( … tic -x - … ) &        # deferred, non-atomic
fi

On a remote host that lacks the entry:

  • The first interactive shell exports TERM=xterm-256color (the install hasn't happened yet), and
  • a later shell pass (the re-exec'd login shell, or a concurrent cmux ssh session sharing $HOME) can select TERM=xterm-ghostty while the background tic is still writing the non-atomic terminfo database — so the TUI starts against a missing or half-written xterm-ghostty entry and scrambles its frame.

Fix

Install the bundled terminfo synchronously and atomically, before deciding TERM, in both the app-side bootstrap builder and the CLI script:

  • Compile the bundled terminfo into a temp directory on the same filesystem as ~/.terminfo, then move each compiled entry into place with an atomic rename, so a concurrent reader in another cmux ssh session never observes a partially-written database.
  • Only select TERM=xterm-ghostty after re-confirming infocmp resolves the entry, so TERM is never xterm-ghostty against an absent/partial terminfo.
  • Graceful fallbacks: direct synchronous compile when mktemp is unavailable; xterm-256color when tic is missing. Neither garbles.

Testing

Two-commit red/green regression test in cmuxTests/ShellStartupMatrixTests.swift (remoteTerminalSetupInstallsGhosttyTerminfoBeforeChoosingTerm):

  • Commit 1 adds the test (red against the background-install code).
  • Commit 2 adds the fix (green).

The test runs the generated setup lines against an isolated $HOME/TERMINFO/TERMINFO_DIRS search path (so the host's own xterm-ghostty can't mask the behavior) and asserts the install resolves xterm-ghostty before TERM is exported.

Validated locally across /bin/sh, /bin/zsh, and bash --posix, including a 12-way concurrency stress (all sessions resolve xterm-ghostty, no corruption, no leftover temp dirs) and nested inside the generated .zshrc heredoc.

Localization

No user-facing strings changed (the diff is shell-script generation only).

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes garbled TUI output in cmux ssh remote workspaces by installing the bundled xterm-ghostty terminfo synchronously and atomically before choosing TERM. Unifies the setup so app and CLI use the same safe path.

  • Bug Fixes
    • Use a single implementation in RemoteInteractiveShellBootstrapBuilder.terminalSetupLines; the CLI now delegates and the duplicate generator was removed.
    • Compile to a temp dir on the same filesystem as ~/.terminfo and atomically rename into place; when mktemp is missing, use per‑process $HOME/.terminfo.cmux.$$ with the same atomic move (no direct writes to ~/.terminfo).
    • Export TERM=xterm-ghostty only after infocmp confirms it; fall back to xterm-256color when tic is unavailable.
    • Add regression test remoteTerminalSetupInstallsGhosttyTerminfoBeforeChoosingTerm to ensure install completes before TERM is exported.

Written for commit 40143e7. Summary will update on new commits.

Review in cubic

cmux and others added 2 commits June 26, 2026 00:59
The remote shell bootstrap installs the bundled xterm-ghostty terminfo in
a background job while deciding TERM synchronously. On a host that lacks
the entry, the first shell pass falls back to xterm-256color (and a later
pass can pick xterm-ghostty while tic is still writing the database), so a
full-screen TUI such as Claude Code renders against a missing/half-written
terminfo entry and garbles its output.

This test runs the generated terminal-setup lines against an isolated
$HOME/terminfo search path so the host's own xterm-ghostty cannot mask the
behavior, and asserts the install resolves xterm-ghostty before TERM is
exported. It fails against the current background-install code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ERM (#6352)

Claude Code (and other full-screen TUIs) rendered garbled output inside a
cmux ssh remote workspace because the remote shell bootstrap installed the
bundled xterm-ghostty terminfo in a background job while deciding TERM up
front. On a host lacking the entry, the first interactive shell fell back to
xterm-256color, and a later shell pass could select xterm-ghostty while the
background tic was still writing the (non-atomic) terminfo database — so the
TUI started against a missing or half-written entry and scrambled its frame.

Make the install synchronous and atomic, in both the app-side bootstrap
builder and the CLI's interactive remote shell script:

- Compile the bundled terminfo into a temp directory on the same filesystem
  as ~/.terminfo, then move each compiled entry into place with an atomic
  rename, so a concurrent reader in another cmux ssh session sharing $HOME
  never observes a partially written database.
- Only select TERM=xterm-ghostty after re-confirming infocmp resolves the
  entry, so TERM is never xterm-ghostty against an absent/partial terminfo.
- Fall back to a direct synchronous compile when mktemp is unavailable, and
  to xterm-256color when tic is missing — both safe, neither garbles.

Validated across /bin/sh, /bin/zsh and bash --posix, with a 12-way
concurrency stress (all sessions resolve xterm-ghostty, no corruption, no
leftover temp dirs) and nested inside the generated .zshrc heredoc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 26, 2026 12:47pm
cmux-staging Building Building Preview, Comment Jun 26, 2026 12:47pm

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@austinywang, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 1 minute and 53 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1b70b34c-8697-46ec-aa3e-2b808c5e7f87

📥 Commits

Reviewing files that changed from the base of the PR and between abbf278 and 40143e7.

📒 Files selected for processing (3)
  • CLI/cmux.swift
  • Sources/RemoteInteractiveShellBootstrapBuilder.swift
  • cmuxTests/ShellStartupMatrixTests.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6352-claude-code-tui-renders-garbled-output

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes garbled TUI output (e.g. Claude Code) in cmux ssh remote workspaces by replacing the background-job terminfo install with a synchronous, atomic temp-dir-then-rename approach, and by removing the duplicated shell-script logic in CLI/cmux.swift so both code paths now share a single implementation in RemoteInteractiveShellBootstrapBuilder.

  • Root-cause fix: terminalSetupLines now compiles the bundled xterm-ghostty terminfo into a private temp directory under $HOME (same filesystem as ~/.terminfo), renames each compiled entry into place with an atomic mv, and only selects TERM=xterm-ghostty after infocmp confirms the entry is resolvable — eliminating the race between concurrent cmux ssh sessions that triggered Claude Code TUI renders garbled output when run inside a cmux ssh remote workspace #6352.
  • Deduplication: CLI/cmux.swift drops its own interactiveRemoteTerminalSetupLines copy and delegates to RemoteInteractiveShellBootstrapBuilder.terminalSetupLines, making drift between the two entrypoints impossible.
  • Regression test: remoteTerminalSetupInstallsGhosttyTerminfoBeforeChoosingTerm runs the generated script against an isolated $HOME/terminfo search path and asserts xterm-ghostty is installed before TERM is exported.

Confidence Score: 5/5

Safe to merge — all three changed files make narrowly scoped improvements with correct fallback behavior and no regressions on existing paths.

The shell-script generation change is well-bounded: every failure mode (mktemp unavailable, tic missing or lacking -o support, mv across filesystems) degrades gracefully to xterm-256color rather than writing partial state. The deduplication in CLI/cmux.swift removes a real drift hazard without changing observable behavior. The new regression test correctly isolates the terminfo search path and validates the synchronous-install invariant. No Swift production paths are touched; no actor isolation, blocking primitives, or logging concerns apply.

No files require special attention.

Important Files Changed

Filename Overview
Sources/RemoteInteractiveShellBootstrapBuilder.swift Rewrites terminalSetupLines to install xterm-ghostty terminfo synchronously via temp-dir-then-atomic-rename before deciding TERM; access widened from private to internal so CLI/cmux.swift can share the single implementation.
CLI/cmux.swift Removes the duplicated interactiveRemoteTerminalSetupLines helper and delegates to RemoteInteractiveShellBootstrapBuilder.terminalSetupLines, eliminating the drift risk between the two entrypoints.
cmuxTests/ShellStartupMatrixTests.swift Adds a regression test that runs the generated setup script against an isolated terminfo search path and asserts xterm-ghostty is resolved before TERM is exported.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([Shell bootstrap starts]) --> B{infocmp xterm-ghostty\nalready present?}
    B -- Yes --> C[cmux_term = 'xterm-ghostty']
    B -- No --> D{tic available?}
    D -- No --> E[cmux_term = 'xterm-256color']
    D -- Yes --> F[mkdir -p ~/.terminfo]
    F --> G[mktemp -d ~/.terminfo.cmux.XXXXXX]
    G -- success --> H[cmux_ti_tmp = mktemp result]
    G -- fail --> I[cmux_ti_tmp = ~/.terminfo.cmux.PID]
    I --> J{mkdir PID dir succeeds?}
    J -- No --> K[cmux_ti_tmp = empty]
    J -- Yes --> H
    H --> L{cmux_ti_tmp non-empty?}
    K --> L
    L -- No --> M[tic skipped]
    L -- Yes --> N[tic -x -o cmux_ti_tmp - reads heredoc]
    N -- fail --> O[rm -rf cmux_ti_tmp]
    N -- success --> P[find cmux_ti_tmp -type f, mv -f each to ~/.terminfo/]
    P --> O
    O --> Q{infocmp xterm-ghostty now resolves?}
    M --> Q
    Q -- Yes --> C
    Q -- No --> E
    C --> R[export TERM=xterm-ghostty]
    E --> S[export TERM=xterm-256color]
    R --> T([TERM exported, TUI safe to start])
    S --> T
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A([Shell bootstrap starts]) --> B{infocmp xterm-ghostty\nalready present?}
    B -- Yes --> C[cmux_term = 'xterm-ghostty']
    B -- No --> D{tic available?}
    D -- No --> E[cmux_term = 'xterm-256color']
    D -- Yes --> F[mkdir -p ~/.terminfo]
    F --> G[mktemp -d ~/.terminfo.cmux.XXXXXX]
    G -- success --> H[cmux_ti_tmp = mktemp result]
    G -- fail --> I[cmux_ti_tmp = ~/.terminfo.cmux.PID]
    I --> J{mkdir PID dir succeeds?}
    J -- No --> K[cmux_ti_tmp = empty]
    J -- Yes --> H
    H --> L{cmux_ti_tmp non-empty?}
    K --> L
    L -- No --> M[tic skipped]
    L -- Yes --> N[tic -x -o cmux_ti_tmp - reads heredoc]
    N -- fail --> O[rm -rf cmux_ti_tmp]
    N -- success --> P[find cmux_ti_tmp -type f, mv -f each to ~/.terminfo/]
    P --> O
    O --> Q{infocmp xterm-ghostty now resolves?}
    M --> Q
    Q -- Yes --> C
    Q -- No --> E
    C --> R[export TERM=xterm-ghostty]
    E --> S[export TERM=xterm-256color]
    R --> T([TERM exported, TUI safe to start])
    S --> T
Loading

Reviews (2): Last reviewed commit: "Unify remote terminfo setup and make the..." | Re-trigger Greptile

Comment thread Sources/RemoteInteractiveShellBootstrapBuilder.swift
Comment thread CLI/cmux.swift Outdated
Comment thread Sources/RemoteInteractiveShellBootstrapBuilder.swift Outdated
Address review feedback on the terminfo install fix:

- Eliminate the duplicated terminfo-install shell generator. The CLI's
  interactiveRemoteTerminalSetupLines was a byte-for-byte copy of the app-side
  RemoteInteractiveShellBootstrapBuilder.terminalSetupLines (both compile into
  the CLI target), so a one-sided future edit could reintroduce #6352 on the
  CLI path with no test catching it. Delete the copy and delegate to the shared
  builder, leaving a single implementation covered by the existing regression
  test. This also gives the internal-visibility widening a production caller.

- Remove the only non-atomic write path. When mktemp is unavailable the install
  now compiles into a per-process $HOME/.terminfo.cmux.$$ directory (unique
  among live processes) and uses the same atomic-rename move, instead of
  compiling directly into ~/.terminfo. No branch writes the terminfo database
  non-atomically, so concurrent cmux ssh sessions sharing $HOME can never
  observe a partial entry.

Validated across /bin/sh, /bin/zsh and bash --posix, including a 12-way
no-mktemp concurrency stress (all sessions resolve xterm-ghostty, no
corruption, no leftover temp dirs) and nested inside the generated .zshrc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 40143e74 Deployed Jun 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude Code TUI renders garbled output when run inside a cmux ssh remote workspace

1 participant