Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 6 additions & 26 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9405,7 +9405,12 @@ struct CMUXCLI {
shellFeatures: String,
terminfoSource: String? = nil
) -> String {
let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource)
// Share the single terminfo-install implementation with the app-side SSH
// PTY bootstrap so the two entrypoints can never drift (the synchronous
// install that fixes #6352 lives only in the builder).
let remoteTerminalLines = RemoteInteractiveShellBootstrapBuilder.terminalSetupLines(
terminfoSource: terminfoSource
)
let remoteLocaleLines = RemoteShellEnvironment.utf8LocaleSetupLines()
let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures)
let shellStateDir = shellStateDirForRemoteRelayPort(remoteRelayPort)
Expand Down Expand Up @@ -9598,31 +9603,6 @@ struct CMUXCLI {
return posixShellCommand(script)
}

private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] {
var lines: [String] = [
"cmux_term='xterm-256color'",
"if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then",
" cmux_term='xterm-ghostty'",
"fi",
"export TERM=\"$cmux_term\"",
]
guard let terminfoSource else { return lines }
let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTerminfoSource.isEmpty else { return lines }
lines += [
"if [ \"$cmux_term\" != 'xterm-ghostty' ]; then",
" (",
" command -v tic >/dev/null 2>&1 || exit 0",
" mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0",
" cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1",
trimmedTerminfoSource,
"CMUXTERMINFO",
" ) </dev/null >/dev/null 2>&1 &",
"fi",
]
return lines
}

private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] {
let environment = ProcessInfo.processInfo.environment
let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor"
Expand Down
72 changes: 56 additions & 16 deletions Sources/RemoteInteractiveShellBootstrapBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,29 +200,69 @@ enum RemoteInteractiveShellBootstrapBuilder {
return lines
}

private static func terminalSetupLines(terminfoSource: String?) -> [String] {
var lines: [String] = [
static func terminalSetupLines(terminfoSource: String?) -> [String] {
Comment thread
austinywang marked this conversation as resolved.
let trimmedTerminfoSource = terminfoSource?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let trimmedTerminfoSource, !trimmedTerminfoSource.isEmpty else {
// Without a bundled terminfo to install we can only probe what the
// remote already has and fall back to a universally-present entry.
return [
"cmux_term='xterm-256color'",
"if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then",
" cmux_term='xterm-ghostty'",
"fi",
"export TERM=\"$cmux_term\"",
]
}
// Install the bundled xterm-ghostty terminfo *synchronously*, before
// deciding TERM, so a full-screen TUI (e.g. Claude Code) never starts
// against a TERM whose terminfo entry is missing or half-written.
//
// The previous design deferred `tic` to a background job and decided
// TERM up front, so the first shell on a host without the entry got
// xterm-256color while a later pass could select xterm-ghostty mid-write
// and garble output (#6352). Here we compile into a private 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. The temp directory comes from `mktemp` when present,
// otherwise a per-process `$$` directory (unique among live processes) so
// the atomic-rename path applies even without `mktemp` — no branch ever
// compiles terminfo directly into ~/.terminfo.
return [
"cmux_term='xterm-256color'",
"if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then",
" cmux_term='xterm-ghostty'",
"fi",
"export TERM=\"$cmux_term\"",
]
guard let terminfoSource else { return lines }
let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTerminfoSource.isEmpty else { return lines }
lines += [
"if [ \"$cmux_term\" != 'xterm-ghostty' ]; then",
" (",
" command -v tic >/dev/null 2>&1 || exit 0",
" mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0",
" cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1",
"elif command -v tic >/dev/null 2>&1; then",
" mkdir -p \"$HOME/.terminfo\" 2>/dev/null",
" cmux_ti_tmp=$(mktemp -d \"$HOME/.terminfo.cmux.XXXXXX\" 2>/dev/null) || cmux_ti_tmp=''",
" if [ -z \"$cmux_ti_tmp\" ]; then",
" cmux_ti_tmp=\"$HOME/.terminfo.cmux.$$\"",
" rm -rf \"$cmux_ti_tmp\" 2>/dev/null",
" mkdir \"$cmux_ti_tmp\" 2>/dev/null || cmux_ti_tmp=''",
" fi",
" {",
" cat <<'CMUXTERMINFO'",
trimmedTerminfoSource,
"CMUXTERMINFO",
" ) </dev/null >/dev/null 2>&1 &",
" } | {",
" if [ -n \"$cmux_ti_tmp\" ] && tic -x -o \"$cmux_ti_tmp\" - >/dev/null 2>&1; then",
" find \"$cmux_ti_tmp\" -type f 2>/dev/null | while IFS= read -r cmux_ti_file; do",
" cmux_ti_rel=${cmux_ti_file#\"$cmux_ti_tmp\"/}",
" cmux_ti_dest=\"$HOME/.terminfo/$cmux_ti_rel\"",
" mkdir -p \"$(dirname \"$cmux_ti_dest\")\" 2>/dev/null",
" mv -f \"$cmux_ti_file\" \"$cmux_ti_dest\" 2>/dev/null || cp -f \"$cmux_ti_file\" \"$cmux_ti_dest\" 2>/dev/null",
" done",
" fi",
" }",
" [ -n \"$cmux_ti_tmp\" ] && rm -rf \"$cmux_ti_tmp\" 2>/dev/null",
" if infocmp xterm-ghostty >/dev/null 2>&1; then",
" cmux_term='xterm-ghostty'",
" fi",
" unset cmux_ti_tmp cmux_ti_file cmux_ti_rel cmux_ti_dest 2>/dev/null || true",
"fi",
"export TERM=\"$cmux_term\"",
]
return lines
}

private static func shellExportLines(shellFeatures: String) -> [String] {
Expand Down
74 changes: 74 additions & 0 deletions cmuxTests/ShellStartupMatrixTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,80 @@ struct ShellStartupMatrixTests {
)
}

/// Regression for #6352: running Claude Code (or any full-screen TUI) inside
/// a `cmux ssh` remote workspace garbled the output because the remote
/// bootstrap installed the bundled `xterm-ghostty` terminfo in a *background*
/// job while `TERM` was decided synchronously. On a host without the entry,
/// the bootstrap therefore had to either fall back to `xterm-256color` (losing
/// ghostty) or — on a later shell pass — pick `xterm-ghostty` while the
/// background `tic` was still writing the database, so the TUI rendered
/// against a missing/half-written terminfo entry.
///
/// The install must be synchronous: once the bundled terminfo source is
/// available and `tic` exists, the very first shell pass must resolve and
/// select `xterm-ghostty` before exporting `TERM`. This test runs the
/// generated setup lines against an isolated `$HOME`/terminfo search path so
/// the host's own `xterm-ghostty` cannot mask the behavior.
@Test
func remoteTerminalSetupInstallsGhosttyTerminfoBeforeChoosingTerm() throws {
let fileManager = FileManager.default
guard fileManager.isExecutableFile(atPath: "/usr/bin/tic"),
fileManager.isExecutableFile(atPath: "/usr/bin/infocmp")
else {
// Host lacks the terminfo toolchain; the synchronous install path
// cannot be exercised here. cmux CI runners ship both binaries.
return
}

let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-terminfo-\(UUID().uuidString)")
let home = root.appendingPathComponent("home")
let emptyTerminfoDirs = root.appendingPathComponent("empty-terminfo")
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
try fileManager.createDirectory(at: emptyTerminfoDirs, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }

// A minimal but valid `xterm-ghostty` entry: enough for `tic -x` to
// accept it and for `infocmp xterm-ghostty` to resolve it once installed.
let terminfoSource = """
xterm-ghostty|cmux ghostty regression terminfo,
\tam, colors#256, cols#80, lines#24,
\tcup=\\E[%i%p1%d;%p2%dH, clear=\\E[H\\E[2J, cr=^M, cud1=^J,
"""

let lines = RemoteInteractiveShellBootstrapBuilder.terminalSetupLines(
terminfoSource: terminfoSource
)
let script = lines.joined(separator: "\n")
+ "\nprintf 'CMUX_TERMINFO_TEST_TERM=%s\\n' \"$TERM\"\n"

// Isolate the terminfo search path so the host's real `xterm-ghostty`
// entry can't be found: ncurses consults $TERMINFO, then $HOME/.terminfo,
// then $TERMINFO_DIRS. Point all of them at fresh, empty directories so
// the only way to resolve `xterm-ghostty` is the in-script install.
let result = runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"TERMINFO=\(home.path)/.terminfo",
"TERMINFO_DIRS=\(emptyTerminfoDirs.path)",
"PATH=/usr/bin:/bin",
"/bin/sh",
"-c",
script,
],
timeout: 10
)

expectEqual(result.status, 0, result.stderr)
expectFalse(result.timedOut, result.stderr)
expectTrue(
result.stdout.contains("CMUX_TERMINFO_TEST_TERM=xterm-ghostty"),
"remote bootstrap did not install xterm-ghostty terminfo before "
+ "choosing TERM (stdout: \(result.stdout))"
)
}

struct RemoteShellCase: Sendable, CustomTestStringConvertible {
let name: String
let expectedArgs: String
Expand Down
Loading