Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
13 changes: 13 additions & 0 deletions Resources/shell-integration/.zshenv
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ fi
# zsh treats unset ZDOTDIR as if it were HOME. We do the same.
builtin typeset _cmux_file="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_cmux_file" ]] || builtin source -- "$_cmux_file"

if [[ -o interactive \
&& -z "${ZSH_EXECUTION_STRING:-}" \
&& "${CMUX_SHELL_INTEGRATION:-1}" != "0" \
&& -n "${CMUX_SHELL_INTEGRATION_DIR:-}" \
&& -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" \
&& "${TERM:-}" == "xterm-256color" \
&& -z "${CMUX_ZSH_RESTORE_TERM:-}" ]]; then
# Keep startup TERM-compatible prompt/theme selection during shell init,
# then restore the managed xterm-256color identity once zle starts.
builtin export CMUX_ZSH_RESTORE_TERM="$TERM"
builtin export TERM="xterm-ghostty"
fi
} always {
if [[ -o interactive ]]; then
# We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's
Expand Down
17 changes: 17 additions & 0 deletions Resources/shell-integration/cmux-zsh-integration.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -1214,13 +1214,30 @@ _cmux_fix_path() {
add-zsh-hook -d precmd _cmux_fix_path
}

_cmux_restore_terminal_identity_after_startup() {
if [[ -n "${CMUX_ZSH_RESTORE_TERM:-}" ]]; then
builtin export TERM="$CMUX_ZSH_RESTORE_TERM"
builtin unset CMUX_ZSH_RESTORE_TERM
fi

if (( $+functions[add-zle-hook-widget] )); then
add-zle-hook-widget -d line-init _cmux_restore_terminal_identity_after_startup 2>/dev/null
fi
add-zsh-hook -d precmd _cmux_restore_terminal_identity_after_startup 2>/dev/null
}

_cmux_zshexit() {
_cmux_stop_git_head_watch
_cmux_stop_pr_poll_loop
}

autoload -Uz add-zsh-hook
autoload -Uz add-zle-hook-widget 2>/dev/null || true
add-zsh-hook preexec _cmux_preexec
add-zsh-hook precmd _cmux_precmd
add-zsh-hook precmd _cmux_fix_path
add-zsh-hook precmd _cmux_restore_terminal_identity_after_startup
Comment thread
lawrencecchen marked this conversation as resolved.
Outdated
if (( $+functions[add-zle-hook-widget] )); then
add-zle-hook-widget line-init _cmux_restore_terminal_identity_after_startup 2>/dev/null || true
fi
add-zsh-hook zshexit _cmux_zshexit
Binary file added Resources/terminfo-overlay/78/xterm-256color
Binary file not shown.
11 changes: 5 additions & 6 deletions Resources/terminfo-overlay/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
# cmux terminfo overlay

cmux ships Ghostty's `xterm-ghostty` terminfo entry, but the embedded
renderer in cmux has differed from Ghostty's app renderer in how it treats
the "bright" SGR 90-97/100-107 sequences.
cmux ships patched `xterm-ghostty` and `xterm-256color` terminfo entries so
the embedded renderer keeps the same color behavior even when the reported
`TERM` changes.

This overlay patches the terminfo capabilities so that `tput setaf 8` (and
These overlays patch the terminfo capabilities so that `tput setaf 8` (and
similar "bright" colors) uses 256-color indexed sequences (`38;5;<n>m` /
`48;5;<n>m`) rather than SGR 90-97/100-107. This avoids relying on bright SGR
handling and fixes zsh-autosuggestions (default `fg=8`) visibility issues in
cmux.
cmux while preserving Ghostty's truecolor capabilities.

The build phase `Copy Ghostty Resources` overlays this directory onto the app
bundle's `Contents/Resources/terminfo` after copying Ghostty's resources.

32 changes: 25 additions & 7 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3133,15 +3133,14 @@ final class TerminalSurface: Identifiable, ObservableObject {
private var backgroundSurfaceStartQueued = false
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
/// The desired focus state for the Ghostty C surface. May be set before the
/// C surface exists (e.g. during layout restoration); `createSurface` seeds
/// the initial runtime focus state from this value, then keeps using it as a
/// dedup guard to avoid redundant `ghostty_surface_set_focus` calls
/// C surface exists (e.g. during layout restoration); `createSurface`
/// reapplies this value once the runtime surface exists, then keeps using it
/// as a dedup guard to avoid redundant `ghostty_surface_set_focus` calls
/// (prevents prompt redraws with P10k).
///
/// Start unfocused and only opt into focus when the workspace/AppKit focus
/// path explicitly requests it. `createSurface` passes this through as the
/// runtime surface's initial focus state so background panes never need a
/// synthetic focus-loss transition during creation.
/// path explicitly requests it so background panes do not keep a focused
/// state unless the workspace focus path requests it.
private var desiredFocusState: Bool = false
#if DEBUG
private var needsConfirmCloseOverrideForTesting: Bool?
Expand Down Expand Up @@ -3255,6 +3254,22 @@ final class TerminalSurface: Identifiable, ObservableObject {
return merged
}

static let managedTerminalType = "xterm-256color"
static let managedTerminalProgram = "ghostty"
static let managedColorTerm = "truecolor"

static func applyManagedTerminalIdentityEnvironment(
to environment: inout [String: String],
protectedKeys: inout Set<String>
) {
environment["TERM"] = managedTerminalType
protectedKeys.insert("TERM")
environment["COLORTERM"] = managedColorTerm
protectedKeys.insert("COLORTERM")
environment["TERM_PROGRAM"] = managedTerminalProgram
protectedKeys.insert("TERM_PROGRAM")
}

static func mergedStartupEnvironment(
base: [String: String],
protectedKeys: Set<String>,
Expand Down Expand Up @@ -3769,7 +3784,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceCallbackContext?.release()
surfaceCallbackContext = callbackContext
surfaceConfig.scale_factor = scaleFactors.layer
surfaceConfig.focused = desiredFocusState
surfaceConfig.context = surfaceContext
#if DEBUG
let templateFontText = String(format: "%.2f", surfaceConfig.font_size)
Expand All @@ -3790,6 +3804,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
var env = baseConfig.environmentVariables

var protectedStartupEnvironmentKeys: Set<String> = []
Self.applyManagedTerminalIdentityEnvironment(
to: &env,
protectedKeys: &protectedStartupEnvironmentKeys
)
func setManagedEnvironmentValue(_ key: String, _ value: String) {
env[key] = value
protectedStartupEnvironmentKeys.insert(key)
Expand Down
8 changes: 6 additions & 2 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,15 @@ struct cmuxApp: App {
}

if getenv("TERM") == nil {
setenv("TERM", "xterm-ghostty", 1)
setenv("TERM", TerminalSurface.managedTerminalType, 1)
}

if getenv("COLORTERM") == nil {
setenv("COLORTERM", TerminalSurface.managedColorTerm, 1)
}

if getenv("TERM_PROGRAM") == nil {
setenv("TERM_PROGRAM", "ghostty", 1)
setenv("TERM_PROGRAM", TerminalSurface.managedTerminalProgram, 1)
}

if let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap({ String(cString: $0) }) {
Expand Down
154 changes: 146 additions & 8 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,29 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase {
}

final class GhosttyTerminalStartupEnvironmentTests: XCTestCase {
func testApplyManagedTerminalIdentityEnvironmentOverridesInheritedValues() {
var environment = [
"TERM": "xterm-ghostty",
"COLORTERM": "24bit",
"TERM_PROGRAM": "Apple_Terminal",
"CUSTOM_FLAG": "1"
]
var protectedKeys: Set<String> = []

TerminalSurface.applyManagedTerminalIdentityEnvironment(
to: &environment,
protectedKeys: &protectedKeys
)

XCTAssertEqual(environment["TERM"], TerminalSurface.managedTerminalType)
XCTAssertEqual(environment["COLORTERM"], TerminalSurface.managedColorTerm)
XCTAssertEqual(environment["TERM_PROGRAM"], TerminalSurface.managedTerminalProgram)
XCTAssertEqual(environment["CUSTOM_FLAG"], "1")
XCTAssertTrue(protectedKeys.contains("TERM"))
XCTAssertTrue(protectedKeys.contains("COLORTERM"))
XCTAssertTrue(protectedKeys.contains("TERM_PROGRAM"))
}

func testMergedStartupEnvironmentAllowsSessionReplayAndInitialEnvCMUXKeys() {
let replayPath = "/tmp/cmux-replay-\(UUID().uuidString)"
let merged = TerminalSurface.mergedStartupEnvironment(
Expand Down Expand Up @@ -1028,6 +1051,36 @@ final class GhosttyTerminalStartupEnvironmentTests: XCTestCase {
XCTAssertEqual(merged["CMUX_SURFACE_ID"], "managed-surface")
XCTAssertEqual(merged["CUSTOM_FLAG"], "1")
}

func testMergedStartupEnvironmentProtectsManagedTerminalIdentity() {
var baseEnvironment = [
"PATH": "/usr/bin"
]
var protectedKeys: Set<String> = ["PATH"]
TerminalSurface.applyManagedTerminalIdentityEnvironment(
to: &baseEnvironment,
protectedKeys: &protectedKeys
)

let merged = TerminalSurface.mergedStartupEnvironment(
base: baseEnvironment,
protectedKeys: protectedKeys,
additionalEnvironment: [
"TERM": "xterm-ghostty",
"COLORTERM": "24bit",
"TERM_PROGRAM": "Apple_Terminal"
],
initialEnvironmentOverrides: [
"TERM": "screen-256color",
"COLORTERM": "false",
"TERM_PROGRAM": "WarpTerminal"
]
)

XCTAssertEqual(merged["TERM"], TerminalSurface.managedTerminalType)
XCTAssertEqual(merged["COLORTERM"], TerminalSurface.managedColorTerm)
XCTAssertEqual(merged["TERM_PROGRAM"], TerminalSurface.managedTerminalProgram)
}
}

@MainActor
Expand Down Expand Up @@ -2755,6 +2808,75 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
XCTAssertEqual(output, "BEFORE\nAFTER", output)
}

func testShellIntegrationPreservesStartupTermForThemeSelectionBeforeRestoringManagedTerm() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
Comment thread
lawrencecchen marked this conversation as resolved.
Outdated
_cmux_restore_terminal_identity_after_startup
print -r -- "$CMUX_STARTUP_THEME_TERM|$CMUX_STARTUP_THEME_BRANCH|$TERM|${CMUX_ZSH_RESTORE_TERM-unset}"
""",
userZshRCContents: """
export CMUX_STARTUP_THEME_TERM="$TERM"
if [[ $TERM = (*256color|*rxvt*) ]]; then
export CMUX_STARTUP_THEME_BRANCH=extended
else
export CMUX_STARTUP_THEME_BRANCH=basic
fi
"""
)

XCTAssertEqual(output, "xterm-ghostty|basic|xterm-256color|unset", output)
}
Comment thread
lawrencecchen marked this conversation as resolved.

func testShellIntegrationDoesNotSpoofManagedTermForInteractiveCommandMode() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
print -r -- "$CMUX_STARTUP_TERM|$TERM|${CMUX_ZSH_RESTORE_TERM-unset}"
""",
userZshRCContents: """
export CMUX_STARTUP_TERM="$TERM"
"""
)

XCTAssertEqual(output, "xterm-256color|xterm-256color|unset", output)
}

func testShellIntegrationDoesNotSpoofManagedTermWhenIntegrationDisabled() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: false,
command: """
print -r -- "$CMUX_STARTUP_TERM|$TERM|${CMUX_ZSH_RESTORE_TERM-unset}"
""",
userZshRCContents: """
export CMUX_STARTUP_TERM="$TERM"
"""
)

XCTAssertEqual(output, "xterm-256color|xterm-256color|unset", output)
}

func testShellIntegrationDoesNotSpoofManagedTermWhenUserZshEnvDisablesIntegration() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
print -r -- "$CMUX_STARTUP_TERM|$TERM|${CMUX_ZSH_RESTORE_TERM-unset}|${CMUX_SHELL_INTEGRATION:-unset}"
""",
userZshEnvContents: """
export CMUX_SHELL_INTEGRATION=0
""",
userZshRCContents: """
export CMUX_STARTUP_TERM="$TERM"
"""
)

XCTAssertEqual(output, "xterm-256color|xterm-256color|unset|0", output)
}

func testShellIntegrationPublishesOnlyWorkspaceScopedCmuxEnvironmentToTmuxServerAutomatically() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
Expand Down Expand Up @@ -3204,7 +3326,9 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
cmuxLoadGhosttyIntegration: Bool,
cmuxLoadShellIntegration: Bool,
command: String,
extraEnvironment: [String: String] = [:]
extraEnvironment: [String: String] = [:],
userZshEnvContents: String? = nil,
userZshRCContents: String? = nil
) throws -> String {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
Expand All @@ -3214,18 +3338,32 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {

let userZdotdir = root.appendingPathComponent("zdotdir")
try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true)
let userZshEnvContents: String = {
if let path = extraEnvironment["PATH"] {
let escaped = path.replacingOccurrences(of: "\"", with: "\\\"")
return "export PATH=\"\(escaped)\"\n"
var userZshEnvFileContents = "\n"
if let path = extraEnvironment["PATH"] {
let escaped = path.replacingOccurrences(of: "\"", with: "\\\"")
userZshEnvFileContents = "export PATH=\"\(escaped)\"\n"
}
if let userZshEnvContents {
if !userZshEnvFileContents.hasSuffix("\n") {
userZshEnvFileContents.append("\n")
}
return "\n"
}()
try userZshEnvContents.write(
userZshEnvFileContents.append(userZshEnvContents)
if !userZshEnvFileContents.hasSuffix("\n") {
userZshEnvFileContents.append("\n")
}
}
try userZshEnvFileContents.write(
to: userZdotdir.appendingPathComponent(".zshenv"),
atomically: true,
encoding: .utf8
)
if let userZshRCContents {
try userZshRCContents.write(
to: userZdotdir.appendingPathComponent(".zshrc"),
atomically: true,
encoding: .utf8
)
}

let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
Expand Down
27 changes: 9 additions & 18 deletions docs/ghostty-fork.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ When we change the fork, update this document and the parent submodule SHA.
## Current fork changes

Fork rebased onto upstream `main` at `3509ccf78` (`v1.3.1-457-g3509ccf78`) on March 30, 2026.
Current cmux fork head: `0b231db94` (`v1.3.1-472-g0b231db94`).
Current cmux pinned fork head: `ae3cc5d29` (`v1.3.1-473-gae3cc5d29`).
Fork `main` keeps this pin reachable via merge commit `5c781d710`
(`Retain layer-background pin ancestry on main`).

### 1) macOS display link restart on display changes

Expand Down Expand Up @@ -112,23 +114,6 @@ tend to conflict together during rebases.

The fork branch HEAD is now the section 7 layer-background restore commit.

### 9) Initial focus seeding and DECSET 1004 startup behavior

- Commit: `c19c82bfd` (Seed initial focus state and avoid startup focus-report leak)
- Files:
- `include/ghostty.h`
- `macos/Sources/Ghostty/Surface View/SurfaceView.swift`
- `src/Surface.zig`
- `src/apprt/embedded.zig`
- `src/termio/stream_handler.zig`
- Summary:
- Adds an explicit initial `focused` flag to surface creation so host apps can start background panes unfocused.
- Seeds renderer and termio focus bookkeeping from that initial state before the IO thread starts.
- Keeps DECSET 1004 enablement side-effect free so focus sequences are emitted only on subsequent real focus transitions, preventing `CSI I/O` from leaking into shells during pane creation.

The latest cmux-specific focus-reporting change is the section 9 commit. The
current submodule SHA also includes a newer merge from upstream `main`.

## Upstreamed fork changes

### cursor-click-to-move respects OSC 133 click-to-move
Expand All @@ -141,6 +126,12 @@ current submodule SHA also includes a newer merge from upstream `main`.
- Were local in the fork as `8ade43ce5`, `0cf559581`, `312c7b23a`, and `404a3f175`.
- Dropped during the March 30, 2026 rebase because newer Ghostty prompt-marking changes on the refreshed base superseded these fork-only zsh redraw patches, so cmux no longer carries them separately.

### initial focus seeding and DECSET 1004 startup behavior

- Was local in the fork as `c19c82bfd`.
- Dropped from the current pinned fork head when cmux removed the corresponding
app-side initial focus seed and went back to post-create focus sync.

## Merge conflict notes

These files change frequently upstream; be careful when rebasing the fork:
Expand Down
2 changes: 1 addition & 1 deletion ghostty
Submodule ghostty updated 423 files
Loading
Loading