Skip to content

Add real tmux pane proxy support - #3275

Closed
islee23520 wants to merge 14 commits into
manaflow-ai:mainfrom
islee23520:feat/tmux-support
Closed

islee23520 wants to merge 14 commits into
manaflow-ai:mainfrom
islee23520:feat/tmux-support

Conversation

@islee23520

@islee23520 islee23520 commented Apr 29, 2026 •

Copy link
Copy Markdown

Summary

  • Import real tmux sessions as cmux workspaces and route workspace pane operations through real tmux.
  • Add a hidden __real-tmux-pane-proxy helper that renders a single tmux pane through control-mode %output streaming and forwards input with send-keys fallback.
  • Track realTmuxPaneId on terminal panels so split, focus, and close operations stay synchronized with the backing tmux pane.

Verification

  • Checked CONTRIBUTING.md before PR prep.
  • Passed requested smoke test: temporary tmux session through /tmp/cmux-cli __real-tmux-pane-proxy --pane %N with marker output.
  • Review-fix validation: lsp_diagnostics clean on Sources/RightSidebarPanelView.swift, Sources/TabManager.swift, and Sources/GhosttyTerminalView.swift; git diff --check clean; PATH="$HOME/.local/zig/0.15.2:$PATH" ./scripts/reload.sh --tag real-tmux-sync4 succeeded; review_fix_smoke=pass pane=%75.

Related

Notes

  • Input routing uses tmux send-keys because control-mode does not provide a raw stdin byte-write API.
  • Unrelated local changes to Package.swift and Package.resolved were intentionally left out of these commits.

Summary by CodeRabbit

  • New Features

    • Native support for real tmux sessions: create, attach, mirror and manage live tmux workspaces; right-sidebar tmux sessions panel with attach/kill/refresh actions; workspace persistence and restore for tmux sessions.
  • Bug Fixes / Behavior

    • Workspace UI labels show [tmux] vs [native]; browser splits blocked for tmux workspaces; Ctrl+B suppressed for real-tmux surfaces; more resilient workspace resolution and suppression of cross-socket env leakage.
  • Localization

    • Added tmux-related UI strings (en/ja).

@vercel

vercel Bot commented Apr 29, 2026

Copy link
Copy Markdown

@islee23520 is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Apr 29, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 933df53d-fcec-44e3-bc67-166e00e68421

📥 Commits

Reviewing files that changed from the base of the PR and between 5f8ca7f and 6ae8abb.

📒 Files selected for processing (4)
  • Resources/Localizable.xcstrings
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift

📝 Walkthrough

Walkthrough

Adds real‑tmux integration: a tmux‑pane proxy CLI, periodic tmux session reconciliation with persisted session→workspace mapping, UI to list/attach/kill tmux sessions, and routes split/focus/close behavior for tmux‑backed workspaces.

Changes

Cohort / File(s) Summary
CLI & tmux proxy
CLI/cmux.swift
Adds internal __real-tmux-pane-proxy proxying real tmux panes (discover tmux, capture-pane, tmux -C attach-session, forward resize/output and stdin); updates CLI workspace labeling, workspace resolution fallbacks, new-split/new-pane current-workspace handling, and prevents env inheritance when CMUX_SOCKET_PATH conflicts with CMUX_SOCKET.
TabManager & tmux reconciliation
Sources/TabManager.swift
Introduces a periodic tmux reconciliation loop, tmux command helpers (list/select/split/kill/capture), persisted sessionId→workspaceId mapping, create/rename/close logic for tmux sessions, proxy-command startup wiring, and new split APIs (initialInput, initialTerminalRealTmuxPaneId).
Right sidebar UI & session list
Sources/RightSidebarPanelView.swift, Sources/TmuxSessionListView.swift?, Sources/ContentView.swift
Adds .tmux right‑sidebar mode and TmuxSessionListView to list/refresh sessions via tmux list-sessions, manage per-bundle session→workspace JSON mapping, attach/kill actions, and snapshot isTmux flag with tmux kind pill in workspace rows.
Workspace / Persistence / API surface
Sources/Workspace.swift, Sources/SessionPersistence.swift
Persists realTmuxSessionId/realTmuxSessionName and realTmuxPaneId; introduces Workspace.isRealTmuxWorkspace; threads realTmuxPaneId, initialCommand, and initialInput through split/surface creation; blocks browser surfaces in real‑tmux workspaces; extends V1/V2 payloads and behavior for real‑tmux.
Terminal panel & controller
Sources/TerminalPanel.swift, Sources/TerminalController.swift
TerminalPanel stores realTmuxPaneId (initializers updated); V2 surface create/close/split operations special‑case real‑tmux and delegate to TabManager (proxy command flows, allow closing last surface for tmux).
AppDelegate helpers
Sources/AppDelegate.swift
Adds helpers to enumerate main-window workspace IDs and workspaces, plus lookup by workspace UUID or by real tmux session id/name across main-window contexts.
Ghostty & focus behavior
Sources/GhosttyTerminalView.swift, Sources/MainWindowFocusController.swift
Blocks Ctrl+B handling for Ghostty surfaces mapped to real tmux sessions (both keyDown and keyUp); treats .tmux right-sidebar like .sessions/.feed for focus routing and disables direct endpoint focus.
Localization
Resources/Localizable.xcstrings
Adds tmux-related localization keys and translations for sidebar mode and session actions (rightSidebar.mode.tmux, tmux.session.*, tmux.sessions.*).

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as "Right Sidebar UI"
    participant TabManager
    participant TMUX as "tmux binary"
    participant Proxy as "__real-tmux-pane-proxy"
    participant Workspace

    TMUX->>TabManager: list-sessions (periodic)
    TMUX-->>TabManager: return sessions + pane ids + cwd
    TabManager->>TabManager: reconcile with persisted mapping
    alt New session
        TabManager->>Workspace: create workspace(with session id/name)
        TabManager->>Proxy: build proxy command for pane
        Workspace-->>UI: session appears in sidebar
    end

    User->>UI: attach/select session
    UI->>TabManager: select workspace
    TabManager->>TMUX: select-window/select-pane
    TMUX-->>TabManager: confirm pane selected
    TabManager->>Workspace: mark focused

    User->>Workspace: request split
    Workspace->>TabManager: newSplit(allowInRealTmuxWorkspace: true)
    TabManager->>TMUX: split-window / capture-pane
    TMUX-->>TabManager: new pane id
    TabManager->>Proxy: launch proxy attached to new pane
    Proxy-->>Workspace: terminal shows proxied pane
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Poem

🐰
I hopped through sockets, panes aglow,
I mapped each tmux session row by row,
A tiny "tmux" pill now sits in sight,
Ctrl+B hushed so shortcuts sleep at night,
I stitched each pane and brought them home.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers what changed (tmux session imports, proxy helper, pane tracking) and why, but lacks testing details and demo video. It provides verification steps instead of standard testing breakdown, missing checklist completion. Add a 'Testing' section explicitly describing how the change was tested locally (e.g., specific test commands or manual steps). If UI changes occurred, consider adding or referencing a demo video per template requirements. Confirm checklist items.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add real tmux pane proxy support' accurately captures the core feature being introduced—a proxy helper for interfacing with real tmux panes—and aligns with the main changes across the codebase.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Review rate limit: 6/8 reviews remaining, refill in 8 minutes and 46 seconds.

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

@greptile-apps

greptile-apps Bot commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds real tmux session import into cmux workspaces: a __real-tmux-pane-proxy CLI subcommand streams tmux %output control-mode events to the terminal and forwards input via send-keys, a new "Tmux" right-sidebar panel lists and syncs live sessions, and workspace split/close/focus operations are routed through the backing tmux pane.

Two P1 threading issues need to be fixed before merge:

  • TabManager.syncRealTmuxSessions() is dispatched to the main thread every 2 seconds and executes multiple blocking Process.waitUntilExit() calls there — all subprocess I/O must be done off-main per CLAUDE.md policy.
  • TmuxSessionListView.refresh() runs the same blocking subprocess calls on the main actor (via MainActor.run inside .task, and directly in onChange/onAppear), which will freeze the UI on every poll cycle.

Confidence Score: 2/5

Not safe to merge — two P1 threading violations will cause repeated UI freezes for any user with tmux running.

Two independent P1 findings (main-thread blocking in TabManager sync timer and TmuxSessionListView refresh) together push the score below the P1 ceiling of 4.

Sources/TabManager.swift (syncRealTmuxSessions threading) and Sources/RightSidebarPanelView.swift (TmuxSessionListView.refresh threading)

Important Files Changed

Filename Overview
Sources/TabManager.swift Adds real-tmux session sync timer + 350 lines of helpers; sync dispatches to main thread where blocking subprocess calls run every 2 seconds — P1 threading violation per CLAUDE.md policy.
Sources/RightSidebarPanelView.swift Adds TmuxSessionListView with a 1.5 s polling task; refresh() runs blocking subprocess I/O on the main actor via MainActor.run, and duplicates all tmux helpers already in TabManager.
Sources/GhosttyTerminalView.swift Adds shouldBlockRealTmuxShortcut to swallow Ctrl-B in tmux workspaces; incidentally de-indents if handled { return } inside the control-key fast path (style-only regression, compiles correctly).
CLI/cmux.swift Adds __real-tmux-pane-proxy subcommand with raw-mode terminal passthrough; workspace listing now shows [tmux]/[native] tags; normalizeWorkspaceHandle fallback improved.
Sources/Workspace.swift Adds realTmuxSessionId/Name published properties and guards on newTerminalSplit/newBrowserSplit to block native splits in tmux workspaces; looks correct.
Sources/TerminalController.swift Surfaces real-tmux kind/source fields in workspace API responses and routes surface.close/new-split socket commands through tmux path; changes appear correct.
Sources/Panels/TerminalPanel.swift Adds optional realTmuxPaneId property to TerminalPanel and threads it through init; straightforward, no issues.
Sources/ContentView.swift Adds isTmux to SidebarWorkspaceSnapshotBuilder and renders a "tmux" badge in TabItemView; localized string present, snapshot-boundary respected.
Sources/AppDelegate.swift Adds three workspace lookup helpers for real-tmux session matching; clean and straightforward.
Sources/MainWindowFocusController.swift Adds .tmux case to RightSidebarMode focus switch arms, returning false since Tmux panel has no focusable first item; correct stub.

Sequence Diagram

sequenceDiagram
    participant UI as TabManager / TmuxSessionListView
    participant Main as Main Thread
    participant BG as Background Queue
    participant Tmux as tmux process
    participant Proxy as cmux __real-tmux-pane-proxy

    Note over UI,Tmux: Session Sync (every 2 s)
    BG->>Main: DispatchQueue.main.async
    Main->>Tmux: list-sessions (waitUntilExit) blocks main
    Tmux-->>Main: session list
    Main->>Tmux: display-message pane_id (waitUntilExit) blocks main
    Tmux-->>Main: pane id
    Main->>Main: addWorkspace / setCustomTitle

    Note over UI,Proxy: Pane Proxy Lifecycle
    UI->>Proxy: exec cmux __real-tmux-pane-proxy --pane %N
    Proxy->>Tmux: tmux -C attach-session (control mode)
    Tmux-->>Proxy: %output %N payload
    Proxy->>Proxy: decodeRealTmuxControlPayload (octal unescape)
    Proxy-->>UI: raw terminal bytes to stdout

    Note over UI,Proxy: Input Forwarding
    UI->>Proxy: stdin bytes
    Proxy->>Tmux: send-keys -l / -H / C-c / Enter / Escape
Loading

Comments Outside Diff (2)

  1. Sources/TabManager.swift, line 1037-1044 (link)

    P1 Blocking subprocess calls on main thread every 2 seconds

    syncRealTmuxSessions() is dispatched to the main thread via DispatchQueue.main.async and then calls Self.listRealTmuxSessions(), Self.realTmuxCurrentPath(for:), and Self.realTmuxCurrentPaneId(for:) — each of which spawns a Process and blocks with waitUntilExit(). With multiple tmux sessions, this fires multiple blocking subprocess calls on the main thread every 2 seconds, causing visible UI hangs and typing lag. The threading policy in CLAUDE.md is explicit: subprocess/I/O work must stay off-main; only the final model mutations should be dispatched to main.

  2. Sources/RightSidebarPanelView.swift, line 795-820 (link)

    P2 Duplicated tmux helper code across three sites

    realTmuxExecutablePath(), RealTmuxStore, listRealTmuxSessions(), runRealTmuxOutput(), normalizedRealTmuxPaneId(), shellQuoted(), and realTmuxPaneProxyCommand() are copy-pasted into TmuxSessionListView, TabManager, and CMUXCLI. Each copy will drift independently. These should live in a shared RealTmuxSupport utility (a free-function file or an internal struct) imported by all three callers.

Reviews (1): Last reviewed commit: "Wire real tmux panes into cmux panels" | Re-trigger Greptile

Comment on lines +705 to +714
return store
}

private func saveRealTmuxStore(_ store: RealTmuxStore) {
guard let data = try? JSONEncoder().encode(store) else { return }
try? FileManager.default.createDirectory(
at: realTmuxStoreURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? data.write(to: realTmuxStoreURL, options: .atomic)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 refresh() blocks the main actor with subprocess I/O

The .task loop calls await MainActor.run { refresh() }, which runs listRealTmuxSessions(), realTmuxCurrentPaneId(), and realTmuxOutput() — all blocking Process.waitUntilExit() calls — on the main actor. The onAppear and onChange handlers call refresh() directly on the main thread as well. With multiple tmux sessions this will freeze the UI for the duration of each subprocess. The subprocess work needs to be done off-main and only the resulting state updates applied on main.

Comment thread Sources/GhosttyTerminalView.swift Outdated
// If not (e.g. `ignore` keybind), fall through to interpretKeyEvents
// so the IME gets a chance to process this event.
if handled { return }
if handled { return }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Incorrect indentation for if handled { return }

This line was de-indented from 12 spaces (inside the if flags.contains(.control) block) to 8 spaces (matching the closing } of that same block). Swift scoping is brace-based so it compiles correctly, but the indentation now falsely implies this return is outside the control-key fast path, making it appear to guard the fall-through path unconditionally. This will mislead future editors of this latency-sensitive code path.

Suggested change
if handled { return }
if handled { return }

Comment thread Sources/TabManager.swift
Comment on lines 5655 to +5660
/// Create a new terminal surface in the focused pane of the selected workspace
func newSurface() {
// Cmd+T should always focus the newly created surface.
selectedWorkspace?.clearSplitZoom()
selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true)
guard let workspace = selectedWorkspace else { return }
if workspace.isRealTmuxWorkspace,
let focusedPanelId = workspace.focusedPanelId {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 newSurface(initialInput:) silently drops initialInput for tmux workspaces

When workspace.isRealTmuxWorkspace, the method calls createSplit and returns early, discarding the caller-supplied initialInput without any comment or fallback. If this is intentional (input forwarding isn't supported via splits), a brief comment explaining why would prevent future confusion and accidental reintroduction.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/MainWindowFocusController.swift (1)

313-355: ⚠️ Potential issue | 🟡 Minor

Clear tmux pending-first-item state to avoid false-positive focus success.

For .tmux, endpoint focus is intentionally false (Line 346 and Line 543), but pendingRightSidebarFirstItemFocusMode can remain set from Line 317. That leaves a sticky pending state and can make focusRightSidebar(...) return true even when no responder was focused.

Suggested fix
@@
-        pendingRightSidebarFirstItemFocusMode = focusFirstItem ? mode : nil
+        pendingRightSidebarFirstItemFocusMode =
+            (focusFirstItem && mode != .tmux) ? mode : nil
@@
-        let fallbackResult = modeResult ? false : focusFallbackRightSidebarHost()
-        let result = modeResult || fallbackResult || pendingRightSidebarFirstItemFocusMode == mode
+        let fallbackResult = modeResult ? false : focusFallbackRightSidebarHost()
+        let hasPendingEndpointFocus = mode != .tmux && pendingRightSidebarFirstItemFocusMode == mode
+        let result = modeResult || fallbackResult || hasPendingEndpointFocus

Also applies to: 523-545

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/MainWindowFocusController.swift` around lines 313 - 355, In
focusRightSidebar(mode:focusFirstItem:) clear the sticky
pendingRightSidebarFirstItemFocusMode when handling the .tmux branch so the
pending-first-item flag cannot make the method return true incorrectly: inside
the switch case .tmux set pendingRightSidebarFirstItemFocusMode = nil (and keep
modeResult = false) so no stale pending state remains; apply the same change to
the corresponding tmux branch in the other function referenced around lines
523-545 to ensure consistent behavior.
🧹 Nitpick comments (4)
Sources/TerminalController.swift (4)

5172-5173: Use ws.isRealTmuxWorkspace property for consistency.

This hunk computes isRealTmuxWorkspace inline, but hunks 5, 6, 7, and 8 use the ws.isRealTmuxWorkspace property directly. Using the property ensures the detection logic remains centralized and consistent.

♻️ Proposed fix
-            let isRealTmuxWorkspace = ws.realTmuxSessionId != nil || ws.realTmuxSessionName != nil
-            if ws.panels.count <= 1 && !isRealTmuxWorkspace {
+            if ws.panels.count <= 1 && !ws.isRealTmuxWorkspace {

Then update the later usages of isRealTmuxWorkspace to ws.isRealTmuxWorkspace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 5172 - 5173, Replace the
inline computation of isRealTmuxWorkspace with the existing
ws.isRealTmuxWorkspace property to keep detection logic centralized: change the
condition "let isRealTmuxWorkspace = ws.realTmuxSessionId != nil ||
ws.realTmuxSessionName != nil" and subsequent usage in the if-statement to use
"ws.isRealTmuxWorkspace" so the code consistently relies on the workspace
property (refer to the ws.isRealTmuxWorkspace property and the surrounding if
that checks ws.panels.count).

16125-16127: Use tab.isRealTmuxWorkspace property for consistency.

Same issue as hunk 4 - computes inline instead of using the property. Aligning with the property ensures consistent detection logic.

♻️ Proposed fix
-            let isRealTmuxWorkspace = tab.realTmuxSessionId != nil || tab.realTmuxSessionName != nil
-            // Don't close if it's the only surface, unless the surface represents a real tmux session.
-            if tab.panels.count <= 1 && !isRealTmuxWorkspace {
+            // Don't close if it's the only surface, unless the surface represents a real tmux session.
+            if tab.panels.count <= 1 && !tab.isRealTmuxWorkspace {

Then update the later usage of isRealTmuxWorkspace to tab.isRealTmuxWorkspace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 16125 - 16127, Replace the
inline computation of a tmux workspace boolean with the existing property:
change usages that compute isRealTmuxWorkspace as "tab.realTmuxSessionId != nil
|| tab.realTmuxSessionName != nil" to use "tab.isRealTmuxWorkspace" (and update
subsequent references to the variable to use tab.isRealTmuxWorkspace as well) so
detection logic is consistent across TerminalController.swift and avoids
duplicating the condition.

3428-3433: Consistent with hunk 1 - same redundancy note applies.

Same kind/source redundancy as the earlier payload. Consider extracting a helper if this pattern appears in more places.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 3428 - 3433, Redundant
construction of the "kind" and "source" fields repeats the same ternary logic;
extract a small helper (e.g., a function that returns ["kind": ..., "source":
...] or a tuple) and call it where the payload is built to avoid duplication.
Update the payload building here (the block referencing
workspace.isRealTmuxWorkspace and real_tmux / realTmuxSessionId/sessionName) to
use that helper and keep the real_tmux conditional as-is (referencing
workspace.realTmuxSessionId and workspace.realTmuxSessionName) so the produced
JSON remains identical but the kind/source logic is centralized.

2996-3001: kind and source appear redundant here.

Both fields are set to the same value ("real-tmux" or "native"). If this is intentional for forward compatibility (e.g., source may diverge later), consider adding a brief comment. Otherwise, one field may suffice.

The conditional payload structure with v2OrNull for optional fields looks correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 2996 - 3001, The payload sets
both "kind" and "source" to the identical value based on
workspace.isRealTmuxWorkspace which is redundant; either remove one field (e.g.,
drop "source") to avoid duplication or keep both but add a clear comment
explaining the forward-compatibility intent so future readers know they may
diverge; update the block that builds the dictionary (the keys "kind", "source",
and "real_tmux" and the use of workspace.isRealTmuxWorkspace, v2OrNull,
realTmuxSessionId, realTmuxSessionName) accordingly to either omit the duplicate
key or include the explanatory comment above the two assignments.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 17275-17278: realTmuxExecutablePath() currently only checks three
hard-coded paths and should also search the user's PATH; update
realTmuxExecutablePath() to first iterate over PATH components (from
ProcessInfo.processInfo.environment["PATH"] split by ":") and return the first
entry where FileManager.default.isExecutableFile(atPath:) is true for "tmux",
falling back to the existing hard-coded array only if nothing on PATH is
executable, and ensure the function still returns an optional String.
- Around line 17083-17095: The defer restoring terminal attributes is bypassed
when finishProxy() calls exit(), leaving the PTY in raw mode; update
finishProxy() to explicitly restore the original termios before calling exit()
by checking the same hasTermios flag and calling tcsetattr(STDIN_FILENO,
TCSANOW, &originalTermios) (using the originalTermios variable used when setting
raw), then call exit(); apply the same explicit restore at the second occurrence
that mirrors the code using originalTermios/hasTermios so both code paths
restore the tty prior to exiting.

In `@Sources/AppDelegate.swift`:
- Around line 3917-3926: The matching currently uses "realTmuxSessionId ==
sessionId || title == sessionName" which returns wrong workspaces when titles
collide; update mainWindowWorkspaceForRealTmuxSession to first search all
contexts' tabManager.tabs for a workspace where realTmuxSessionId == sessionId
and return it if found, and only if no ID match exists perform a second search
that matches title == sessionName but restricts that fallback to tmux workspaces
(e.g., where realTmuxSessionId != nil or an isTmux flag) to avoid matching
non-tmux workspaces; reference mainWindowWorkspaceForRealTmuxSession,
mainWindowContexts, tabManager.tabs, realTmuxSessionId and title when making the
change.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 7083-7085: The current early return when
shouldBlockRealTmuxShortcut(event, surface: surface) is true swallows Ctrl+B
(tmux prefix) and creates unmatched keyUp events; instead of returning early in
the key handler (where shouldBlockRealTmuxShortcut is called), detect the Ctrl+B
case and forward the event into the tmux input path so tmux receives both press
and release (e.g., call the existing tmux input/forwarding function used
elsewhere) and only suppress local handling for non-tmux shortcuts; update both
keyDown and keyUp paths to use shouldBlockRealTmuxShortcut and the
tmux-forwarding function so events are consistently routed rather than dropped.

In `@Sources/RightSidebarPanelView.swift`:
- Around line 646-649: realTmuxExecutablePath currently only checks three
hard-coded locations and misses tmux installs on PATH (MacPorts, Nix, custom
paths); update realTmuxExecutablePath() to first search PATH for an executable
named "tmux" (e.g., inspect ProcessInfo.processInfo.environment["PATH"] and test
each component with FileManager.isExecutableFile(atPath:) or run `which tmux`
via Process) and return that if found, then fall back to the existing array
["/opt/homebrew/bin/tmux","/usr/local/bin/tmux","/usr/bin/tmux"] as a secondary
lookup; keep the function signature and behavior of returning an optional String
and use the same FileManager check for fallbacks.
- Around line 511-515: The onKill closure currently calls
killRealTmuxSession(session.name) and immediately closes the mapped workspace
via tabManager.closeWorkspaceWithConfirmation; change killRealTmuxSession to
return a Bool (or Result) that reflects the tmux process terminationStatus (or
thrown error), and in onKill only call
tabManager.closeWorkspaceWithConfirmation(workspace) after receiving a
successful result (or after performing a refresh check that confirms the tmux
session no longer exists). Update the killRealTmuxSession signature and all
callers (including the similar pattern used in the other onKill location) to
await/inspect the success flag and handle failures by logging or refreshing
instead of removing the workspace immediately.
- Around line 523-541: The refresh() path currently runs blocking tmux and file
I/O on the MainActor; change it to perform tmux polling,
Process.waitUntilExit(), Data(contentsOf:), JSON encoding/decoding, and any
store reads/writes inside a Task.detached (or otherwise off the MainActor) at
.utility priority to produce a snapshot object (e.g. via a new
collectTmuxSnapshot() or similar function), then marshal back to the MainActor
and apply only the UI mutations (sessions, workspace, and tabManager) in an
apply(snapshot) function; update the recurring task to call the new async
refresh() (which awaits the detached task and then calls MainActor.run to apply)
so the 1.5s polling loop does not hop to MainActor before doing blocking work.
- Around line 549-573: The pruning/import code currently filters and resolves
workspaces using only self.tabManager.tabs, causing cross-window tmux sessions
to be dropped or duplicated; change the lookup to search across all main window
contexts’ tab managers (e.g., aggregate tabs from mainWindowContexts'
tabManager.tabs) when building liveWorkspaceIds and when finding an existing
workspace for a session (instead of tabManager.tabs.first(where: ...)). Ensure
the store.sessionIdToWorkspaceId filter uses that aggregated set and that the
existing-workspace resolution and subsequent tabManager.addWorkspace fallback
remain the same but only run if no workspace exists across any main window
context.

In `@Sources/TabManager.swift`:
- Around line 5477-5482: The early return in focusSurface(tabId:surfaceId:)
drops explicit surface-focus requests for tmux-backed workspaces; instead when
tab.isRealTmuxWorkspace is true call selectRealTmuxPane(tabId:surfaceId:) (or
the appropriate selectRealTmuxPane method) to route the focus to the tmux pane
and also update local selection state (e.g., still call
tab.focusPanel(surfaceId) or update the tab's focusedSurface) so the app
reflects the explicit focus-intent; ensure this code path is used only for
explicit in-app focus commands and does not change global macOS app focus for
socket/CLI-triggered events.
- Around line 5668-5673: The newSurface(initialInput:) branch for real-tmux
workspaces calls createSplit(tabId:surfaceId:direction:) but never passes or
applies the initialInput, causing startup text to be lost for tmux-backed panes;
modify the tmux split path to either accept an initialInput parameter (update
createSplit signature or add a new createSplitWithInitialInput) and thread the
initialInput through to the created pane, or after createSplit returns use the
created pane identifier to send the initialInput into that pane (e.g., via the
same method non-tmux splits use); apply the same fix to the analogous code block
around the other occurrence referenced (the block at the later newSurface call).
- Around line 1221-1228: The current matching falls back to tabs.first {
$0.realTmuxSessionId == session.id || $0.title == session.name }, which can
incorrectly convert a local workspace when its title equals a tmux session name;
remove the title==session.name fallback so we only adopt when a persisted or
already-tmux-backed workspace is found. Change the lookup to use
AppDelegate.shared?.mainWindowWorkspaceForRealTmuxSession(id:name:) or
tabs.first(where: { $0.realTmuxSessionId == session.id }) only, then set
store.sessionIdToWorkspaceId[session.id] and update
existing.realTmuxSessionId/realTmuxSessionName as before (symbols:
mainWindowWorkspaceForRealTmuxSession, tabs, realTmuxSessionId,
store.sessionIdToWorkspaceId, session.id, session.name,
existing.realTmuxSessionName).
- Around line 4902-4905: The current logic gates tmux close routing by
tab.isRealTmuxWorkspace, which fails for browser panels that lack a tmux pane;
change both occurrences (the block around closeRealTmuxPanel(tab: panelId:) and
the similar block at the other occurrence) to check the specific panel's real
tmux pane id by calling tab.terminalPanel(for: panelId)?.realTmuxPaneId and only
call closeRealTmuxPanel(...) when that pane id exists (otherwise fall back to
the non-tmux close path). Ensure you reference and use tab.terminalPanel(for:),
realTmuxPaneId, and closeRealTmuxPanel(tab:panelId:) when making the conditional
change.
- Around line 1207-1212: syncRealTmuxSessions() calls closeWorkspace(...) when a
tmux session is gone but closeWorkspace bails out if tabs.count <= 1, leaving
the real-tmux session stale; fix by detecting when the to-be-closed workspace is
the last tab (using tabs and store.sessionIdToWorkspaceId + liveSessionIds) and
either create a replacement workspace in that window first (via whatever
create/open-new-workspace API your codebase provides) before calling
closeWorkspace, or route this cleanup through the existing user-close path that
can handle removing the last workspace so the real tmux session is cleaned up;
ensure you reference syncRealTmuxSessions and closeWorkspace and handle the
last-tab case explicitly.
- Around line 1186-1197: startRealTmuxSessionSyncTimer currently schedules the
timer to hop onto DispatchQueue.main and calls syncRealTmuxSessions on the main
actor, but syncRealTmuxSessions performs blocking Process.waitUntilExit(), file
reads, and JSON writes; move the tmux probe and store I/O off the main actor by
running the heavy work on a background queue inside the timer handler and only
dispatch back to the main queue for quick workspace/store mutations and UI
updates. Specifically, update startRealTmuxSessionSyncTimer's
timer.setEventHandler to call syncRealTmuxSessions (or a new helper like
probeRealTmuxSessions) on a global/utility queue, ensure syncRealTmuxSessions
does not perform blocking operations on the main thread (extract blocking
Process.waitUntilExit, file reads/writes into background-only code), and when
results are ready dispatch minimal state-updating code back to
DispatchQueue.main to update the workspace/store and assign
realTmuxSessionSyncTimer as before.
- Around line 1230-1239: Compute a single boolean (e.g. hasRealTmux) and only
treat the session as a real‑tmux workspace when both
Self.realTmuxCurrentPaneId(for: session) and the proxy command from
Self.realTmuxPaneProxyCommand(...) are non‑nil; pass
initialTerminalRealTmuxPaneId = hasRealTmux ? initialPaneId : nil and
initialTerminalCommand = hasRealTmux ? initialProxyCommand : nil, otherwise
supply the attach input (Self.realTmuxAttachInput(for: session)) as
initialTerminalInput so addWorkspace(...) is called as a non‑real‑tmux import
when a real tmux pane id/proxy is not available.

In `@Sources/TerminalController.swift`:
- Around line 14644-14648: The code path where tabManager.newSplit(...) returns
nil lacks error handling and leaves result unset; update the branch so that when
newSplit returns nil you set result to an internal error string (matching the V2
pattern, e.g. "internal_error new_split_failed") before returning; locate the
call to tabManager.newSplit(tabId: tabId, surfaceId: focusedPanelId, direction:
direction, focus: focus) and ensure the else path sets result appropriately and
returns.

In `@Sources/Workspace.swift`:
- Around line 6647-6651: sessionSnapshot(...) and restoreSessionSnapshot(...)
currently only persist generic terminal state so real tmux identity
(realTmuxSessionId, realTmuxSessionName) and per-panel realTmuxPaneId are lost
across restarts; update the snapshot model to include realTmuxSessionId and
realTmuxSessionName on the Workspace snapshot and include realTmuxPaneId on each
Panel snapshot, serialize those fields in sessionSnapshot(...) and rehydrate
them in restoreSessionSnapshot(...), and if you prefer opting out add an
explicit flag (e.g., isRealTmuxWorkspace or persistRealTmuxIdentity) to the
snapshot so real tmux workspaces can be excluded from restore when set.

---

Outside diff comments:
In `@Sources/MainWindowFocusController.swift`:
- Around line 313-355: In focusRightSidebar(mode:focusFirstItem:) clear the
sticky pendingRightSidebarFirstItemFocusMode when handling the .tmux branch so
the pending-first-item flag cannot make the method return true incorrectly:
inside the switch case .tmux set pendingRightSidebarFirstItemFocusMode = nil
(and keep modeResult = false) so no stale pending state remains; apply the same
change to the corresponding tmux branch in the other function referenced around
lines 523-545 to ensure consistent behavior.

---

Nitpick comments:
In `@Sources/TerminalController.swift`:
- Around line 5172-5173: Replace the inline computation of isRealTmuxWorkspace
with the existing ws.isRealTmuxWorkspace property to keep detection logic
centralized: change the condition "let isRealTmuxWorkspace =
ws.realTmuxSessionId != nil || ws.realTmuxSessionName != nil" and subsequent
usage in the if-statement to use "ws.isRealTmuxWorkspace" so the code
consistently relies on the workspace property (refer to the
ws.isRealTmuxWorkspace property and the surrounding if that checks
ws.panels.count).
- Around line 16125-16127: Replace the inline computation of a tmux workspace
boolean with the existing property: change usages that compute
isRealTmuxWorkspace as "tab.realTmuxSessionId != nil || tab.realTmuxSessionName
!= nil" to use "tab.isRealTmuxWorkspace" (and update subsequent references to
the variable to use tab.isRealTmuxWorkspace as well) so detection logic is
consistent across TerminalController.swift and avoids duplicating the condition.
- Around line 3428-3433: Redundant construction of the "kind" and "source"
fields repeats the same ternary logic; extract a small helper (e.g., a function
that returns ["kind": ..., "source": ...] or a tuple) and call it where the
payload is built to avoid duplication. Update the payload building here (the
block referencing workspace.isRealTmuxWorkspace and real_tmux /
realTmuxSessionId/sessionName) to use that helper and keep the real_tmux
conditional as-is (referencing workspace.realTmuxSessionId and
workspace.realTmuxSessionName) so the produced JSON remains identical but the
kind/source logic is centralized.
- Around line 2996-3001: The payload sets both "kind" and "source" to the
identical value based on workspace.isRealTmuxWorkspace which is redundant;
either remove one field (e.g., drop "source") to avoid duplication or keep both
but add a clear comment explaining the forward-compatibility intent so future
readers know they may diverge; update the block that builds the dictionary (the
keys "kind", "source", and "real_tmux" and the use of
workspace.isRealTmuxWorkspace, v2OrNull, realTmuxSessionId, realTmuxSessionName)
accordingly to either omit the duplicate key or include the explanatory comment
above the two assignments.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fc7cdd89-b3ad-40e1-8f8a-e2080f38873f

📥 Commits

Reviewing files that changed from the base of the PR and between a34eb1f and 4898a76.

📒 Files selected for processing (10)
  • CLI/cmux.swift
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/MainWindowFocusController.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/RightSidebarPanelView.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift

Comment thread CLI/cmux.swift
Comment on lines +17083 to +17095
setbuf(stdout, nil)
var originalTermios = termios()
let hasTermios = tcgetattr(STDIN_FILENO, &originalTermios) == 0
if hasTermios {
var rawTermios = originalTermios
cfmakeraw(&rawTermios)
_ = tcsetattr(STDIN_FILENO, TCSANOW, &rawTermios)
}
defer {
if hasTermios {
_ = tcsetattr(STDIN_FILENO, TCSANOW, &originalTermios)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Swift, does calling exit()terminate the process before enclosingdefer blocks execute?

💡 Result:

No, calling exit in Swift (imported from Darwin) terminates the process immediately without executing enclosing defer blocks.

Citations:


🏁 Script executed:

# First, locate the file and check its size
find . -name "cmux.swift" -type f

Repository: manaflow-ai/cmux

Length of output: 77


🏁 Script executed:

# Check the lines around 17083-17095 to see the defer block
sed -n '17080,17100p' CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 752


🏁 Script executed:

# Check lines 17123-17134 to see the duplicate issue
sed -n '17120,17140p' CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 853


🏁 Script executed:

# Find the finishProxy function to see where exit() is called
rg -A 20 "func finishProxy" CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 875


🏁 Script executed:

# Check if hasTermios is defined at function scope or earlier in main context
rg -B 50 "func finishProxy" CLI/cmux.swift | grep -E "(hasTermios|var|let)" | tail -20

Repository: manaflow-ai/cmux

Length of output: 757


Restore the tty before calling exit() in finishProxy().

The defer block restores terminal attributes when the function exits normally, but finishProxy() calls exit() directly, which terminates the process immediately and bypasses all defer blocks. This leaves the pane PTY in raw mode after teardown.

Add explicit terminal restoration before the exit() call in finishProxy():

Suggested fix
         func finishProxy(status: Int32) -> Never {
+            if hasTermios {
+                _ = tcsetattr(STDIN_FILENO, TCSANOW, &originalTermios)
+            }
             if !didFinish {
                 didFinish = true
                 controlOutput.fileHandleForReading.readabilityHandler = nil
                 controlError.fileHandleForReading.readabilityHandler = nil
                 if controlProcess.isRunning {
                     controlInput.fileHandleForWriting.write(Data("detach-client\n".utf8))
                     controlProcess.terminate()
                 }
             }
             exit(status)
         }

Also applies to the second occurrence at lines 17123-17134.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 17083 - 17095, The defer restoring terminal
attributes is bypassed when finishProxy() calls exit(), leaving the PTY in raw
mode; update finishProxy() to explicitly restore the original termios before
calling exit() by checking the same hasTermios flag and calling
tcsetattr(STDIN_FILENO, TCSANOW, &originalTermios) (using the originalTermios
variable used when setting raw), then call exit(); apply the same explicit
restore at the second occurrence that mirrors the code using
originalTermios/hasTermios so both code paths restore the tty prior to exiting.

Comment thread CLI/cmux.swift
Comment on lines +17254 to +17267
for byte in data {
switch byte {
case 0x03:
flushLiteralBuffer()
_ = runRealTmux(tmuxPath: tmuxPath, arguments: ["send-keys", "-t", paneId, "C-c"])
case 0x04:
flushLiteralBuffer()
_ = runRealTmux(tmuxPath: tmuxPath, arguments: ["send-keys", "-t", paneId, "C-d"])
case 0x0D, 0x0A:
flushLiteralBuffer()
_ = runRealTmux(tmuxPath: tmuxPath, arguments: ["send-keys", "-t", paneId, "Enter"])
case 0x1B:
flushLiteralBuffer()
_ = runRealTmux(tmuxPath: tmuxPath, arguments: ["send-keys", "-t", paneId, "Escape"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In tmux, what happens if an arrow-key sequence (ESC [ A) is forwarded as send-keys Escapeplus literal[andA, instead of preserving the raw byte sequence?

💡 Result:

In tmux, forwarding an arrow-key sequence as separate send-keys Escape followed by literal [ and A (instead of the raw byte sequence ESC [ A) sends three distinct keystrokes to the target pane: first ESC (0x1B), then [ (0x5B), then A (0x41). These are not combined or interpreted as a CSI sequence by tmux itself during sending. The target application (e.g., shell, vim) receives them as individual characters/keystrokes. Whether they form an arrow key depends on the application's input parser and timing: - Many TUIs (like vim, bash with showkey -a) use an escape-time timeout (default ~500ms in tmux/vim) to distinguish lone ESC from escape sequences. If [ and A arrive quickly after ESC (typical in send-keys), it may parse as Up arrow (CSI A). - If delayed (e.g., slow network), or if escape-time is high, it treats them separately: ESC cancels/escapes, then literal [ and A are inserted. This differs from raw ESC [ A (sent via send-keys -l $'\e[ A' or send-keys -H 1b 5b 41), which arrives as a contiguous byte stream more reliably parsed as arrow by applications. tmux send-keys with named keys like Up sends the exact same bytes as raw CSI (per key table matching terminfo), preserving the sequence. Separate sends risk timeout misinterpretation. To send raw bytes literally without key name lookup: send-keys -l "string" or -H for hex.

Citations:


🏁 Script executed:

# Check the file size and examine the context around lines 17254-17267
wc -l CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 81


🏁 Script executed:

# Read the context around the flagged lines, including function signature
sed -n '17200,17300p' CLI/cmux.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 4744


🏁 Script executed:

# Search for the function definition containing these lines
sed -n '17000,17254p' CLI/cmux.swift | tail -100 | cat -n

Repository: manaflow-ai/cmux

Length of output: 4747


Don't send ESC as a standalone key—it breaks CSI/SS3 sequences.

Line 66–68 treats every 0x1B byte as a standalone Escape key sent to tmux. This breaks escape sequences like arrow keys (ESC [ A), function keys, and Alt+key combinations that use CSI/SS3 encoding. When ESC is sent separately via send-keys Escape, the following bytes [ and A are sent in subsequent send-keys calls, causing the target application's escape-time timeout (~500 ms in tmux/vim) to expire between them. The application then interprets ESC as a cancel/escape, and [ and A as literal characters instead of part of the arrow-key sequence.

Use send-keys -l (literal mode) or -H (hex mode) to preserve raw byte sequences together, or buffer ESC with lookahead to detect and handle complete CSI/SS3 sequences as atomic units.

Comment thread CLI/cmux.swift
Comment on lines +17275 to +17278
private func realTmuxExecutablePath() -> String? {
["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]
.first { FileManager.default.isExecutableFile(atPath: $0) }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Look up tmux on PATH before failing.

realTmuxExecutablePath() only checks three hard-coded locations. Valid installs from Nix, MacPorts, custom Homebrew prefixes, or wrapper-based setups will hit tmux executable not found even though tmux is available.

♻️ Suggested fix
     private func realTmuxExecutablePath() -> String? {
-        ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]
-            .first { FileManager.default.isExecutableFile(atPath: $0) }
+        if let path = ProcessInfo.processInfo.environment["PATH"] {
+            for entry in path.split(separator: ":") {
+                let candidate = String(entry) + "/tmux"
+                if FileManager.default.isExecutableFile(atPath: candidate) {
+                    return candidate
+                }
+            }
+        }
+        return ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]
+            .first { FileManager.default.isExecutableFile(atPath: $0) }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func realTmuxExecutablePath() -> String? {
["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]
.first { FileManager.default.isExecutableFile(atPath: $0) }
}
private func realTmuxExecutablePath() -> String? {
if let path = ProcessInfo.processInfo.environment["PATH"] {
for entry in path.split(separator: ":") {
let candidate = String(entry) + "/tmux"
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
}
return ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]
.first { FileManager.default.isExecutableFile(atPath: $0) }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 17275 - 17278, realTmuxExecutablePath()
currently only checks three hard-coded paths and should also search the user's
PATH; update realTmuxExecutablePath() to first iterate over PATH components
(from ProcessInfo.processInfo.environment["PATH"] split by ":") and return the
first entry where FileManager.default.isExecutableFile(atPath:) is true for
"tmux", falling back to the existing hard-coded array only if nothing on PATH is
executable, and ensure the function still returns an optional String.

Comment thread Sources/AppDelegate.swift
Comment on lines +3917 to +3926
func mainWindowWorkspaceForRealTmuxSession(id sessionId: String, name sessionName: String) -> Workspace? {
for context in mainWindowContexts.values {
if let workspace = context.tabManager.tabs.first(where: {
$0.realTmuxSessionId == sessionId || $0.title == sessionName
}) {
return workspace
}
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tighten real-tmux workspace matching to avoid false positives.

$0.realTmuxSessionId == sessionId || $0.title == sessionName can resolve the wrong workspace when titles collide (including non-tmux workspaces). This can misroute tmux pane actions. Prefer: ID match first, then tmux-only fallback by name.

Proposed fix
 func mainWindowWorkspaceForRealTmuxSession(id sessionId: String, name sessionName: String) -> Workspace? {
-    for context in mainWindowContexts.values {
-        if let workspace = context.tabManager.tabs.first(where: {
-            $0.realTmuxSessionId == sessionId || $0.title == sessionName
-        }) {
-            return workspace
-        }
-    }
-    return nil
+    for context in mainWindowContexts.values {
+        if let workspace = context.tabManager.tabs.first(where: { $0.realTmuxSessionId == sessionId }) {
+            return workspace
+        }
+    }
+
+    guard !sessionName.isEmpty else { return nil }
+    for context in mainWindowContexts.values {
+        if let workspace = context.tabManager.tabs.first(where: {
+            $0.realTmuxSessionId != nil && $0.title == sessionName
+        }) {
+            return workspace
+        }
+    }
+    return nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func mainWindowWorkspaceForRealTmuxSession(id sessionId: String, name sessionName: String) -> Workspace? {
for context in mainWindowContexts.values {
if let workspace = context.tabManager.tabs.first(where: {
$0.realTmuxSessionId == sessionId || $0.title == sessionName
}) {
return workspace
}
}
return nil
}
func mainWindowWorkspaceForRealTmuxSession(id sessionId: String, name sessionName: String) -> Workspace? {
for context in mainWindowContexts.values {
if let workspace = context.tabManager.tabs.first(where: { $0.realTmuxSessionId == sessionId }) {
return workspace
}
}
guard !sessionName.isEmpty else { return nil }
for context in mainWindowContexts.values {
if let workspace = context.tabManager.tabs.first(where: {
$0.realTmuxSessionId != nil && $0.title == sessionName
}) {
return workspace
}
}
return nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3917 - 3926, The matching currently
uses "realTmuxSessionId == sessionId || title == sessionName" which returns
wrong workspaces when titles collide; update
mainWindowWorkspaceForRealTmuxSession to first search all contexts'
tabManager.tabs for a workspace where realTmuxSessionId == sessionId and return
it if found, and only if no ID match exists perform a second search that matches
title == sessionName but restricts that fallback to tmux workspaces (e.g., where
realTmuxSessionId != nil or an isTmux flag) to avoid matching non-tmux
workspaces; reference mainWindowWorkspaceForRealTmuxSession, mainWindowContexts,
tabManager.tabs, realTmuxSessionId and title when making the change.

Comment on lines +7083 to +7085
if shouldBlockRealTmuxShortcut(event, surface: surface) {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't swallow Ctrl+B in real tmux workspaces.

This turns tmux's default prefix into a no-op for imported sessions, so users can't type core tmux commands from the keyboard. It also leaves keyUp sending a release without a matching press. If this key needs special handling, route it through the tmux input path instead of returning early here.

💡 Minimal fix
-        if shouldBlockRealTmuxShortcut(event, surface: surface) {
-            return
-        }
-    private func shouldBlockRealTmuxShortcut(_ event: NSEvent, surface: ghostty_surface_t) -> Bool {
-        let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
-        guard flags.contains(.control),
-              !flags.contains(.command),
-              !flags.contains(.option),
-              !flags.contains(.shift),
-              event.keyCode == kVK_ANSI_B,
-              let located = AppDelegate.shared?.locateGhosttySurface(surface),
-              let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }),
-              workspace.realTmuxSessionId != nil else {
-            return false
-        }
-#if DEBUG
-        cmuxDebugLog(
-            "realTmux.key.block workspace=\(workspace.id.uuidString.prefix(5)) " +
-            "session=\(workspace.realTmuxSessionName ?? workspace.realTmuxSessionId ?? "") key=ctrl-b"
-        )
-#endif
-        return true
-    }

Also applies to: 7461-7480

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 7083 - 7085, The current
early return when shouldBlockRealTmuxShortcut(event, surface: surface) is true
swallows Ctrl+B (tmux prefix) and creates unmatched keyUp events; instead of
returning early in the key handler (where shouldBlockRealTmuxShortcut is
called), detect the Ctrl+B case and forward the event into the tmux input path
so tmux receives both press and release (e.g., call the existing tmux
input/forwarding function used elsewhere) and only suppress local handling for
non-tmux shortcuts; update both keyDown and keyUp paths to use
shouldBlockRealTmuxShortcut and the tmux-forwarding function so events are
consistently routed rather than dropped.

Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/TabManager.swift
Comment thread Sources/TabManager.swift
Comment on lines +14644 to +14648
if let id = tabManager.newSplit(tabId: tabId, surfaceId: focusedPanelId, direction: direction, focus: focus) {
result = "OK \(id.uuidString) [tmux]"
}
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing error handling when newSplit fails.

If tabManager.newSplit returns nil, the function returns without setting result, leaving it undefined/default. The V2 equivalent (hunk 6) correctly handles this with an internal_error response.

🐛 Proposed fix
             if let id = tabManager.newSplit(tabId: tabId, surfaceId: focusedPanelId, direction: direction, focus: focus) {
                 result = "OK \(id.uuidString) [tmux]"
+            } else {
+                result = "ERROR: Failed to split real tmux pane"
             }
             return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 14644 - 14648, The code path
where tabManager.newSplit(...) returns nil lacks error handling and leaves
result unset; update the branch so that when newSplit returns nil you set result
to an internal error string (matching the V2 pattern, e.g. "internal_error
new_split_failed") before returning; locate the call to
tabManager.newSplit(tabId: tabId, surfaceId: focusedPanelId, direction:
direction, focus: focus) and ensure the else path sets result appropriately and
returns.

Comment thread Sources/Workspace.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (10)
Sources/GhosttyTerminalView.swift (1)

7083-7085: ⚠️ Potential issue | 🔴 Critical

Don’t swallow Ctrl+B in real tmux workspaces.

Line 7083 drops the key entirely for real tmux sessions, so tmux never receives its default prefix. It also leaves key handling asymmetric because keyUp still goes through normal release routing.

Minimal safe fix
-        if shouldBlockRealTmuxShortcut(event, surface: surface) {
-            return
-        }
-    private func shouldBlockRealTmuxShortcut(_ event: NSEvent, surface: ghostty_surface_t) -> Bool {
-        let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
-        guard flags.contains(.control),
-              !flags.contains(.command),
-              !flags.contains(.option),
-              !flags.contains(.shift),
-              event.keyCode == kVK_ANSI_B,
-              let located = AppDelegate.shared?.locateGhosttySurface(surface),
-              let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }),
-              workspace.realTmuxSessionId != nil else {
-            return false
-        }
-#if DEBUG
-        cmuxDebugLog(
-            "realTmux.key.block workspace=\(workspace.id.uuidString.prefix(5)) " +
-            "session=\(workspace.realTmuxSessionName ?? workspace.realTmuxSessionId ?? "") key=ctrl-b"
-        )
-#endif
-        return true
-    }

Also applies to: 7461-7480

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 7083 - 7085, The current
early-return when shouldBlockRealTmuxShortcut(event, surface: surface) is true
swallows Ctrl+B for real tmux workspaces and breaks symmetry with keyUp; instead
of returning, allow the event to be forwarded to normal input handling so tmux
receives the prefix and keyUp is routed the same way—replace the bare return in
the keyDown handling with code that forwards the event (e.g., call the same path
used for non-blocked events or surface.handleEvent/nextResponder) only block
events for the synthetic/unsupported cases that should genuinely be dropped;
ensure the same gating is applied in keyUp handling so press/release are
consistent.
Sources/TabManager.swift (6)

5707-5714: ⚠️ Potential issue | 🟠 Major

newSurface(initialInput:) still loses the startup input on tmux-backed paths.

The tmux split path creates a pane, but it still has no way to carry the caller's initialInput, so commands that rely on startup text behave differently after tmux import.

Also applies to: 5877-5893

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5707 - 5714, The
newSurface(initialInput:) path for tmux-backed workspaces discards the caller's
initialInput when it calls createSplit(tabId:surfaceId:direction:), so startup
text is lost; modify the split flow to propagate and apply the initialInput: add
an initialInput parameter to createSplit (or otherwise pass/store the text via a
pendingInputs map keyed by the created pane/surface id), ensure the tmux split
code uses that value to send the input after the proxy/attachment completes (or
inject it when the backing pane is ready), and update callers (including
newSurface and any other call sites) to pass through the initialInput so
tmux-backed panes receive the startup command.

4941-4944: ⚠️ Potential issue | 🟠 Major

Gate tmux close routing on the addressed panel, not the whole workspace.

A real-tmux workspace can still contain non-terminal panels. Routing every close through closeRealTmuxPanel(...) makes browser-panel closes no-op because there is no realTmuxPaneId to kill.

Also applies to: 6083-6085

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 4941 - 4944, The current check uses
tab.isRealTmuxWorkspace to route every close through closeRealTmuxPanel, which
incorrectly no-ops for non-terminal panels; instead gate on the addressed
panel's tmux identity (e.g., check the panel object for realTmuxPaneId or
panel.isRealTmuxPanel) and only call closeRealTmuxPanel(tab:tab,
panelId:panelId) when that specific panel has a realTmuxPaneId; otherwise
perform the normal non-tmux close path. Apply the same change to the duplicate
occurrence around the other snippet (the block at the 6083-6085 location).

1259-1264: ⚠️ Potential issue | 🟠 Major

Don't adopt a workspace just because its title matches the tmux session name.

The title == session.name fallback can still silently convert a normal local workspace into a real-tmux workspace when names collide.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1259 - 1264, The code is incorrectly
adopting workspaces by matching tmux session.name against workspace.title;
remove the title-based fallback so we only treat a workspace as a real-tmux
mapping when AppDelegate.shared?.mainWindowWorkspaceForRealTmuxSession(id:name:)
returns a workspace or when a tab already has realTmuxSessionId == session.id.
Update the logic in the loop over realSessions that sets
store.sessionIdToWorkspaceId[session.id] (references: realSessions,
store.sessionIdToWorkspaceId,
AppDelegate.shared?.mainWindowWorkspaceForRealTmuxSession(id:name:), tabs,
realTmuxSessionId, title) to drop the `title == session.name` check and only use
the explicit real-tmux identifiers.

1246-1251: ⚠️ Potential issue | 🟠 Major

Sync cleanup still can't remove the last workspace in a window.

When a backing tmux session disappears and this is the only workspace in the window, closeWorkspace(...) returns immediately, so the stale real-tmux workspace stays open forever.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1246 - 1251, The cleanup loop over
store.sessionIdToWorkspaceId skips closing a workspace when it's the last in its
window because closeWorkspace(...) currently early-returns for last-workspace
cases; change closeWorkspace (or add a parameter like
forceCloseWhenBackingMissing) so the call from the sync-cleanup loop can
force-close a workspace even if it's the final tab in its window when the
backing tmux session is gone: detect the missing backing session in the loop
(using liveSessionIds) and call closeWorkspace(workspace, killRealTmuxSession:
false, force: true) or equivalent, and update closeWorkspace to honor that force
flag by bypassing the "last workspace" early-return when force is true.

1269-1283: ⚠️ Potential issue | 🟠 Major

Avoid importing a real-tmux workspace without an initial pane id.

This still sets realTmuxSessionId/realTmuxSessionName even when no proxy command is available, so later pane-scoped operations have no realTmuxPaneId to target.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1269 - 1283, The code sets
realTmuxSessionId and realTmuxSessionName on the workspace even if
initialProxyCommand is nil, which means no valid initial pane id exists and
later pane-related operations will lack a target. To fix this in the block where
you add a workspace for a real-tmux session, conditionally assign
realTmuxSessionId and realTmuxSessionName only if initialProxyCommand is non-nil
so that only workspaces with valid pane proxies are marked as real-tmux
sessions.

5516-5520: ⚠️ Potential issue | 🟠 Major

Explicit surface-focus requests are still dropped in real-tmux workspaces.

Returning early here means callers cannot focus a specific tmux-backed pane via focusSurface(...); this should select the tmux pane and still update local panel focus.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5516 - 5520, The early return in
focusSurface(tabId:surfaceId:) when tab.isRealTmuxWorkspace drops explicit focus
requests; remove the return and instead, for real-tmux tabs, invoke the
tmux-pane selection logic (the same code path used elsewhere to select a
tmux-backed pane) and then proceed to update the local panel focus/state just
like non-tmux tabs so callers can both select the tmux pane and update local UI
focus; locate focusSurface and replace the guard/return behavior for
tab.isRealTmuxWorkspace with a branch that triggers the tmux selection call and
continues to the common focus-update steps.
Sources/RightSidebarPanelView.swift (3)

569-576: ⚠️ Potential issue | 🟠 Major

Cross-window lookup is still missing.

applyRefresh builds liveWorkspaceIds and searches for existing workspaces using only the local tabManager.tabs. If the tmux sidebar is opened in a second window, it will drop mappings for sessions whose workspaces live in the first window, then recreate duplicates on the next refresh.

Per retrieved learnings, workspace resolution should search across all mainWindowContexts (e.g., via AppDelegate.shared?.workspaceFor(tabId:) or aggregating tabs from all contexts) to avoid active-window bias.

Also applies to: 598-598

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RightSidebarPanelView.swift` around lines 569 - 576, applyRefresh
currently builds liveWorkspaceIds and matches sessions only against the local
tabManager.tabs which drops mappings for workspaces in other app windows; update
applyRefresh to resolve workspaces across all windows by querying global
contexts (e.g., use AppDelegate.shared?.workspaceFor(tabId:) or aggregate tabs
from all mainWindowContexts) when constructing liveWorkspaceIds and when doing
the lookup inside the for session in realSessions loop so
store.sessionIdToWorkspaceId retains mappings to workspaces in other windows
(ensure you reference liveWorkspaceIds, tabManager.tabs,
store.sessionIdToWorkspaceId, and realSessions in the updated logic).

512-516: ⚠️ Potential issue | 🟠 Major

killRealTmuxSession still ignores termination status before closing workspace.

The onKill closure unconditionally closes the mapped workspace after calling killRealTmuxSession, but the function discards the process exit code. If tmux kill-session fails (e.g., session already gone, permission error), the tmux session may remain alive while cmux removes its workspace, desynchronizing state.

Return a success indicator and gate the workspace close on it:

🔧 Proposed fix
-    private func killRealTmuxSession(_ sessionName: String) {
+    `@discardableResult`
+    private func killRealTmuxSession(_ sessionName: String) -> Bool {
         guard let tmuxPath = realTmuxExecutablePath() else { return }
         let process = Process()
         process.executableURL = URL(fileURLWithPath: tmuxPath)
         process.arguments = ["kill-session", "-t", sessionName]
         process.standardOutput = Pipe()
         process.standardError = Pipe()
-        try? process.run()
-        process.waitUntilExit()
+        do {
+            try process.run()
+            process.waitUntilExit()
+            return process.terminationStatus == 0
+        } catch {
+            return false
+        }
     }

Then in onKill:

                             onKill: {
-                                killRealTmuxSession(session.name)
-                                if let workspace = tabManager.tabs.first(where: { $0.id == session.workspaceId }) {
-                                    _ = tabManager.closeWorkspaceWithConfirmation(workspace)
+                                if killRealTmuxSession(session.name),
+                                   let workspace = tabManager.tabs.first(where: { $0.id == session.workspaceId }) {
+                                    tabManager.closeWorkspaceWithConfirmation(workspace)
                                 }
                             }

Also applies to: 745-754

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RightSidebarPanelView.swift` around lines 512 - 516, The onKill
closure calls killRealTmuxSession(session.name) but ignores its exit status, so
change killRealTmuxSession to return a Bool (or Result) indicating
success/failure (or the process exit code) and update all callers (the onKill
closure here and the similar block around lines 745-754) to gate closing the
mapped workspace: call killRealTmuxSession(session.name), check the returned
success flag (or exit code == 0) before calling
tabManager.closeWorkspaceWithConfirmation(workspace), and log/handle failures
instead of unconditionally closing the workspace (referencing symbols:
killRealTmuxSession, onKill closure, session.name, session.workspaceId,
tabManager.closeWorkspaceWithConfirmation, tabManager.tabs).

563-622: ⚠️ Potential issue | 🟡 Minor

Store I/O (load/save) still runs on the main thread.

applyRefresh is dispatched to DispatchQueue.main.async (line 557-558), so loadRealTmuxStore() and saveRealTmuxStore(_:) perform file reads/writes on the main thread. This can stall the sidebar when the disk is slow or the JSON grows.

Move the store load into the background task alongside the tmux calls, and write the store asynchronously after applying UI changes (or batch it with the next refresh).

Also applies to: 724-739

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RightSidebarPanelView.swift` around lines 563 - 622, applyRefresh
currently calls loadRealTmuxStore() and saveRealTmuxStore(_:) on the main thread
(applyRefresh is dispatched via DispatchQueue.main.async), which can block the
UI; move the store I/O off the main thread by loading the store earlier in the
background task that fetches tmux data (so replace direct calls to
loadRealTmuxStore() in applyRefresh with a store parameter or a preloaded
variable), perform all mutation of UI models (workspace updates, sessions array,
isRefreshing) on the main thread as you already do, and persist the updated
store asynchronously after UI updates (call saveRealTmuxStore(_) from a
background queue or batch it with the next refresh); update references to
functions/vars: applyRefresh, loadRealTmuxStore, saveRealTmuxStore, tabManager,
sessions, and ensure workspace.realTmuxSessionId/name assignments and
workspace.setCustomTitle(...) remain on the main thread while file I/O is moved
off.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/RightSidebarPanelView.swift`:
- Around line 745-754: The killRealTmuxSession function runs a Process
synchronously and calls process.waitUntilExit(), which blocks the main thread;
update it to run the Process asynchronously (e.g., create and run the Process on
a background queue or use Process.terminationHandler) and remove the blocking
waitUntilExit(); after the Process finishes invoke UI-related follow-up on the
main thread (DispatchQueue.main.async) so callers from the button tap (which use
killRealTmuxSession) no longer block the UI; reference the existing function
killRealTmuxSession and the helper realTmuxExecutablePath() when making the
change.

In `@Sources/TabManager.swift`:
- Around line 1243-1257: The current filter on store.sessionIdToWorkspaceId
wrongly drops mappings for workspaces not present in this TabManager
(refreshedWorkspaceIds), breaking cross-window tmux mappings; update the
filtering logic in the TabManager code that touches store.sessionIdToWorkspaceId
so you only remove entries whose sessionId is no longer live (use
liveSessionIds.contains($0.key)) and do NOT require the workspace id to be in
this TabManager's tabs/refreshedWorkspaceIds; keep the existing
closeWorkspace(session cleanup) behavior (closeWorkspace and liveSessionIds
logic) but remove the refreshedWorkspaceIds.contains($0.value) condition so
mappings for other windows are preserved.

---

Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 7083-7085: The current early-return when
shouldBlockRealTmuxShortcut(event, surface: surface) is true swallows Ctrl+B for
real tmux workspaces and breaks symmetry with keyUp; instead of returning, allow
the event to be forwarded to normal input handling so tmux receives the prefix
and keyUp is routed the same way—replace the bare return in the keyDown handling
with code that forwards the event (e.g., call the same path used for non-blocked
events or surface.handleEvent/nextResponder) only block events for the
synthetic/unsupported cases that should genuinely be dropped; ensure the same
gating is applied in keyUp handling so press/release are consistent.

In `@Sources/RightSidebarPanelView.swift`:
- Around line 569-576: applyRefresh currently builds liveWorkspaceIds and
matches sessions only against the local tabManager.tabs which drops mappings for
workspaces in other app windows; update applyRefresh to resolve workspaces
across all windows by querying global contexts (e.g., use
AppDelegate.shared?.workspaceFor(tabId:) or aggregate tabs from all
mainWindowContexts) when constructing liveWorkspaceIds and when doing the lookup
inside the for session in realSessions loop so store.sessionIdToWorkspaceId
retains mappings to workspaces in other windows (ensure you reference
liveWorkspaceIds, tabManager.tabs, store.sessionIdToWorkspaceId, and
realSessions in the updated logic).
- Around line 512-516: The onKill closure calls
killRealTmuxSession(session.name) but ignores its exit status, so change
killRealTmuxSession to return a Bool (or Result) indicating success/failure (or
the process exit code) and update all callers (the onKill closure here and the
similar block around lines 745-754) to gate closing the mapped workspace: call
killRealTmuxSession(session.name), check the returned success flag (or exit code
== 0) before calling tabManager.closeWorkspaceWithConfirmation(workspace), and
log/handle failures instead of unconditionally closing the workspace
(referencing symbols: killRealTmuxSession, onKill closure, session.name,
session.workspaceId, tabManager.closeWorkspaceWithConfirmation,
tabManager.tabs).
- Around line 563-622: applyRefresh currently calls loadRealTmuxStore() and
saveRealTmuxStore(_:) on the main thread (applyRefresh is dispatched via
DispatchQueue.main.async), which can block the UI; move the store I/O off the
main thread by loading the store earlier in the background task that fetches
tmux data (so replace direct calls to loadRealTmuxStore() in applyRefresh with a
store parameter or a preloaded variable), perform all mutation of UI models
(workspace updates, sessions array, isRefreshing) on the main thread as you
already do, and persist the updated store asynchronously after UI updates (call
saveRealTmuxStore(_) from a background queue or batch it with the next refresh);
update references to functions/vars: applyRefresh, loadRealTmuxStore,
saveRealTmuxStore, tabManager, sessions, and ensure
workspace.realTmuxSessionId/name assignments and workspace.setCustomTitle(...)
remain on the main thread while file I/O is moved off.

In `@Sources/TabManager.swift`:
- Around line 5707-5714: The newSurface(initialInput:) path for tmux-backed
workspaces discards the caller's initialInput when it calls
createSplit(tabId:surfaceId:direction:), so startup text is lost; modify the
split flow to propagate and apply the initialInput: add an initialInput
parameter to createSplit (or otherwise pass/store the text via a pendingInputs
map keyed by the created pane/surface id), ensure the tmux split code uses that
value to send the input after the proxy/attachment completes (or inject it when
the backing pane is ready), and update callers (including newSurface and any
other call sites) to pass through the initialInput so tmux-backed panes receive
the startup command.
- Around line 4941-4944: The current check uses tab.isRealTmuxWorkspace to route
every close through closeRealTmuxPanel, which incorrectly no-ops for
non-terminal panels; instead gate on the addressed panel's tmux identity (e.g.,
check the panel object for realTmuxPaneId or panel.isRealTmuxPanel) and only
call closeRealTmuxPanel(tab:tab, panelId:panelId) when that specific panel has a
realTmuxPaneId; otherwise perform the normal non-tmux close path. Apply the same
change to the duplicate occurrence around the other snippet (the block at the
6083-6085 location).
- Around line 1259-1264: The code is incorrectly adopting workspaces by matching
tmux session.name against workspace.title; remove the title-based fallback so we
only treat a workspace as a real-tmux mapping when
AppDelegate.shared?.mainWindowWorkspaceForRealTmuxSession(id:name:) returns a
workspace or when a tab already has realTmuxSessionId == session.id. Update the
logic in the loop over realSessions that sets
store.sessionIdToWorkspaceId[session.id] (references: realSessions,
store.sessionIdToWorkspaceId,
AppDelegate.shared?.mainWindowWorkspaceForRealTmuxSession(id:name:), tabs,
realTmuxSessionId, title) to drop the `title == session.name` check and only use
the explicit real-tmux identifiers.
- Around line 1246-1251: The cleanup loop over store.sessionIdToWorkspaceId
skips closing a workspace when it's the last in its window because
closeWorkspace(...) currently early-returns for last-workspace cases; change
closeWorkspace (or add a parameter like forceCloseWhenBackingMissing) so the
call from the sync-cleanup loop can force-close a workspace even if it's the
final tab in its window when the backing tmux session is gone: detect the
missing backing session in the loop (using liveSessionIds) and call
closeWorkspace(workspace, killRealTmuxSession: false, force: true) or
equivalent, and update closeWorkspace to honor that force flag by bypassing the
"last workspace" early-return when force is true.
- Around line 1269-1283: The code sets realTmuxSessionId and realTmuxSessionName
on the workspace even if initialProxyCommand is nil, which means no valid
initial pane id exists and later pane-related operations will lack a target. To
fix this in the block where you add a workspace for a real-tmux session,
conditionally assign realTmuxSessionId and realTmuxSessionName only if
initialProxyCommand is non-nil so that only workspaces with valid pane proxies
are marked as real-tmux sessions.
- Around line 5516-5520: The early return in focusSurface(tabId:surfaceId:) when
tab.isRealTmuxWorkspace drops explicit focus requests; remove the return and
instead, for real-tmux tabs, invoke the tmux-pane selection logic (the same code
path used elsewhere to select a tmux-backed pane) and then proceed to update the
local panel focus/state just like non-tmux tabs so callers can both select the
tmux pane and update local UI focus; locate focusSurface and replace the
guard/return behavior for tab.isRealTmuxWorkspace with a branch that triggers
the tmux selection call and continues to the common focus-update steps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: da7edd4f-b65d-4068-acf1-286282d1595d

📥 Commits

Reviewing files that changed from the base of the PR and between 4898a76 and 51c5461.

📒 Files selected for processing (3)
  • Sources/GhosttyTerminalView.swift
  • Sources/RightSidebarPanelView.swift
  • Sources/TabManager.swift

Comment thread Sources/RightSidebarPanelView.swift Outdated
Comment thread Sources/TabManager.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (16)
Sources/GhosttyTerminalView.swift (1)

7191-7193: ⚠️ Potential issue | 🔴 Critical

Don’t swallow Ctrl+B in real tmux workspaces.

On Line 7192, returning early drops the tmux prefix key press. keyUp still follows the normal release path, so real tmux panes lose prefix behavior and key transitions become asymmetric. Route this case to the tmux forwarding path instead of returning early (or remove this block).

💡 Minimal fix
-        if shouldBlockRealTmuxShortcut(event, surface: surface) {
-            return
-        }
+        // Do not swallow Ctrl+B for real tmux; let normal input path deliver it.

Also applies to: 7569-7588

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 7191 - 7193, The early return
in the key handling branch that checks shouldBlockRealTmuxShortcut(event,
surface: surface) swallows the tmux prefix (Ctrl+B) on keyDown and breaks
symmetry with keyUp; instead of returning, route this case into the existing
tmux forwarding path (the same flow used for forwarding other tmux events) so
the prefix is forwarded to the real tmux workspace and keyUp behavior remains
symmetrical—replace the return with a call to the tmux-forwarding handler used
elsewhere (or remove the block so execution falls through to forwarding),
keeping references to shouldBlockRealTmuxShortcut, the current event, surface,
and keyUp handling.
Sources/AppDelegate.swift (1)

3918-3927: ⚠️ Potential issue | 🟠 Major

Avoid broad title fallback for tmux workspace resolution.

On Line 3921, matching title == sessionName can select the wrong workspace when titles collide (including non-tmux workspaces), which can misroute tmux pane actions.

Proposed fix
 func mainWindowWorkspaceForRealTmuxSession(id sessionId: String, name sessionName: String) -> Workspace? {
     for context in mainWindowContexts.values {
-        if let workspace = context.tabManager.tabs.first(where: {
-            $0.realTmuxSessionId == sessionId || $0.title == sessionName
-        }) {
+        if let workspace = context.tabManager.tabs.first(where: {
+            $0.realTmuxSessionId == sessionId
+        }) {
             return workspace
         }
     }
+
+    guard !sessionName.isEmpty else { return nil }
+    for context in mainWindowContexts.values {
+        if let workspace = context.tabManager.tabs.first(where: {
+            $0.isRealTmuxWorkspace && $0.title == sessionName
+        }) {
+            return workspace
+        }
+    }
     return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3918 - 3927, The current
mainWindowWorkspaceForRealTmuxSession function is matching workspaces by title
(title == sessionName) which can pick non-tmux workspaces; change the predicate
so it only matches real tmux sessions by realTmuxSessionId. Either remove the
title fallback entirely from the closure in
context.tabManager.tabs.first(where:) and only compare $0.realTmuxSessionId ==
sessionId, or if you must keep a title-based fallback, restrict it to workspaces
that are already tmux-bound (e.g. only allow $0.title == sessionName when
$0.realTmuxSessionId != nil or another tmux-marker is present) so title
collisions won't select non-tmux workspaces.
Sources/RightSidebarPanelView.swift (4)

572-579: ⚠️ Potential issue | 🟠 Major

The global tmux mapping still has active-window bias.

realTmuxStoreURL is shared across the app, but pruning and existing-workspace resolution only inspect tabManager.tabs from the current window. Opening this sidebar in a second window can drop mappings for tmux workspaces hosted in the first window and recreate duplicates locally on the next refresh. Based on learnings, workspace resolution in this repo should search across all main-window contexts rather than the active window to avoid active-window bias.

Also applies to: 597-601

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RightSidebarPanelView.swift` around lines 572 - 579, The
prune-and-resolve logic uses tabManager.tabs (active window only) causing
active-window bias; change it to aggregate tabs from all main-window contexts
before computing liveWorkspaceIds and when resolving existing workspaces for
realSessions. Replace uses of tabManager.tabs with a collectedTabs array built
by querying the global window registry/main controllers (e.g.,
allWindowControllers.flatMap { $0.tabs }) and use collectedTabs in the Set
creation and in the first(where:) lookups; apply the same change to the other
block referenced (lines 597-601) so pruning and resolution search across all
windows.

673-676: ⚠️ Potential issue | 🟠 Major

Search PATH before falling back to fixed tmux locations.

This will miss valid installs from MacPorts, Nix, and custom PATH setups, so the tmux panel can look empty even though tmux is available in the user's environment.

#!/bin/bash
rg -n -C2 'realTmuxExecutablePath|/opt/homebrew/bin/tmux|/usr/local/bin/tmux|/usr/bin/tmux' Sources/RightSidebarPanelView.swift
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RightSidebarPanelView.swift` around lines 673 - 676,
realTmuxExecutablePath currently only checks a hardcoded list of locations and
misses tmux installs on MacPorts, Nix or custom PATHs; update
realTmuxExecutablePath to first search the user PATH (use
ProcessInfo.processInfo.environment["PATH"] split on ":" and check each
candidate with FileManager.default.isExecutableFile(atPath:)) and return the
first match, and only if that fails fall back to the existing hardcoded array
["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]; keep the
function nonisolated and preserve the FileManager checks so callers of
realTmuxExecutablePath see the same return semantics.

515-519: ⚠️ Potential issue | 🟠 Major

Only close the workspace after kill-session succeeds, and don't block the button tap.

onKill removes the mapped workspace unconditionally, while killRealTmuxSession ignores terminationStatus and waits synchronously. If tmux rejects the target, cmux still drops the workspace; if tmux is slow, the UI blocks until the child exits. Return a success flag asynchronously and only close after a confirmed kill (or after a refresh proves the session is gone).

#!/bin/bash
rg -n -C2 'onKill:|killRealTmuxSession|waitUntilExit|closeWorkspaceWithConfirmation' Sources/RightSidebarPanelView.swift

Also applies to: 748-756

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RightSidebarPanelView.swift` around lines 515 - 519, The onKill
handler currently calls killRealTmuxSession(session.name) synchronously and
immediately closes the mapped workspace via
tabManager.closeWorkspaceWithConfirmation(workspace); change killRealTmuxSession
to an asynchronous API that returns a success flag (via completion handler,
async/await, or Result) and do not await it on the main thread; instead, kick
off the kill asynchronously and only call
tabManager.closeWorkspaceWithConfirmation(workspace) after the kill completes
successfully (or after a follow-up refresh confirms the tmux session no longer
exists); update both occurrences (the onKill closure around
killRealTmuxSession(session.name) and the similar block at the later lines) to
use the new async success check so the UI button tap is non-blocking and the
workspace is only removed on confirmed kill.

560-562: ⚠️ Potential issue | 🟠 Major

Keep the tmux-store JSON I/O off the main queue.

The tmux polling moved off-main, but loadRealTmuxStore() and saveRealTmuxStore() still run after the main-queue hop inside applyRefresh. That leaves disk I/O in the 1.5s refresh path and can still hitch the sidebar when the home directory is slow.

Also applies to: 566-618, 727-741

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RightSidebarPanelView.swift` around lines 560 - 562, The tmux JSON
disk I/O (calls to loadRealTmuxStore() and saveRealTmuxStore()) must be moved
off the main queue: perform those calls on a background queue (e.g.,
DispatchQueue.global(qos:.utility).async) and only hop back to
DispatchQueue.main.async to call UI-updating code such as applyRefresh(...) or
to commit results to the view; alternatively, have applyRefresh accept the
already-loaded tmux store/result and call saveRealTmuxStore from a background
queue before/after UI updates. Ensure you capture any returned data/error from
loadRealTmuxStore() on the background thread and pass it into applyRefresh on
the main thread, and do not call loadRealTmuxStore() or saveRealTmuxStore()
directly from inside DispatchQueue.main.async.
Sources/Workspace.swift (2)

7206-7210: ⚠️ Potential issue | 🟠 Major

Persist the real-tmux identity, or explicitly opt these workspaces out of restore.

These fields are now part of the workspace model, but Lines 321-336 still serialize only the generic SessionWorkspaceSnapshot, and Lines 543-557 / 694-779 still omit per-panel realTmuxPaneId rehydration. After a relaunch, a real-tmux workspace will come back as plain local terminals with no backing-pane routing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 7206 - 7210, The snapshot
serialization/deserialization needs to include the real-Tmux identity and
per-panel pane IDs: update the code paths that create and restore
SessionWorkspaceSnapshot to persist realTmuxSessionId and realTmuxSessionName
when isRealTmuxWorkspace is true, and update the workspace rehydration logic
(the code that reconstructs panels/TerminalPanel state) to read and set each
panel's realTmuxPaneId during restore; ensure the snapshot model and the restore
function that iterates panels both handle these new fields so real-tmux
workspaces are rehydrated with backing-pane routing instead of falling back to
local terminals.

9875-9888: ⚠️ Potential issue | 🟠 Major

newTerminalSurface(...) still bypasses the real-tmux invariant.

This guard only covers split creation. Line 12244 still goes through createTerminalToRight(...) -> newTerminalSurface(...), and Line 13709 keeps that context-menu path reachable, so a real-tmux workspace can still end up with a native cmux terminal that has no backing tmux pane.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 9875 - 9888, The
newTerminalSurface(...) function (and any code paths that call it like
createTerminalToRight(...) and the context-menu creation path) is still allowed
to create a native cmux terminal in a real-tmux workspace; add the same
real-tmux invariant check at the top of newTerminalSurface(...) (or make a
single helper guard) so it returns nil unless allowInRealTmuxWorkspace is true,
and update callers (createTerminalToRight and the context-menu terminal
creation) to either pass allowInRealTmuxWorkspace when appropriate or stop
calling newTerminalSurface in real-tmux workspaces; reference
newTerminalSurface(...), createTerminalToRight(...), and the context-menu
creation path when making the change.
Sources/TabManager.swift (8)

4941-4944: ⚠️ Potential issue | 🟠 Major

Gate tmux close routing on the panel, not the whole workspace.

Browser or other non-terminal panels inside a real-tmux workspace do not have a realTmuxPaneId, so these branches turn close into a no-op when closeRealTmuxPanel(...) returns false. Check tab.terminalPanel(for: panelId)?.realTmuxPaneId first and only route those panels through tmux.

Also applies to: 6096-6098

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 4941 - 4944, Currently the code
unconditionally routes all panels in a real tmux workspace through
closeRealTmuxPanel by checking tab.isRealTmuxWorkspace, which makes
non-terminal/browser panels (which lack a realTmuxPaneId) become no-ops when
closeRealTmuxPanel returns false; change the gate to check the specific panel’s
realTmuxPaneId by calling tab.terminalPanel(for: panelId)?.realTmuxPaneId and
only call closeRealTmuxPanel(tab: panelId:) when that optional pane id exists.
Apply the same fix to the other occurrence around the code near the 6096–6098
region so only true tmux terminal panels are routed to closeRealTmuxPanel.

5720-5727: ⚠️ Potential issue | 🟠 Major

newSurface(initialInput:) still drops the startup input in real-tmux workspaces.

The tmux branch creates the split but never forwards initialInput, so commands that rely on startup text behave differently after tmux import. Thread the input through the tmux split path or send it to the newly created pane immediately after creation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5720 - 5727, In
newSurface(initialInput:), when workspace.isRealTmuxWorkspace is true the code
currently calls createSplit(tabId:surfaceId:direction:) and returns without
forwarding initialInput; modify the tmux branch to either (a) extend createSplit
to accept an optional initialInput parameter and pass initialInput through, or
(b) capture the created pane/surface id returned by createSplit and immediately
call the existing input-forwarding API (e.g. sendInputToSurface / writeToPane /
whatever central method your codebase uses) to write initialInput into the new
surface; ensure you reference selectedWorkspace, workspace.focusedPanelId and
createSplit in the change so the startup input is not dropped.

1238-1242: ⚠️ Potential issue | 🟠 Major

Real-tmux sync still does store I/O on the main actor.

The tmux probing moved off-main, but applyRealTmuxSessionSync() still synchronously loads and saves the JSON store on the main actor every pass. Slow home-directory I/O will still hitch UI/typing here; decode/encode the store in the background phase and only apply the already-loaded result on main.

Also applies to: 1304-1304


1269-1283: ⚠️ Potential issue | 🟠 Major

Don't import as real-tmux unless the initial pane proxy exists.

This branch still marks the workspace as real-tmux when currentPaneId/realTmuxPaneProxyCommand(...) is unavailable. The initial terminal then has no realTmuxPaneId, but later pane-scoped operations still route through tmux, so the imported workspace becomes partially unusable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1269 - 1283, The code marks workspaces
as real-tmux even when there is no initial pane proxy; change it so
workspace.realTmuxSessionId, workspace.realTmuxSessionName and
store.sessionIdToWorkspaceId[session.id] are only set when initialProxyCommand
(computed via Self.realTmuxPaneProxyCommand from initialPaneId) is non-nil.
Leave addWorkspace call as-is but wrap the assignments to
workspace.realTmuxSessionId / realTmuxSessionName and the store mapping in a
conditional that checks initialProxyCommand != nil so imports without a proxy
are not treated as real-tmux.

5529-5534: ⚠️ Potential issue | 🟠 Major

Explicit surface-focus requests still no-op in tmux-backed workspaces.

This drops callers that intentionally target a specific pane, so focusTab(..., surfaceId:) cannot focus a tmux-backed surface after import. Route the request through selectRealTmuxPane(...) while keeping the local focused panel in sync. Based on learnings: Socket/CLI commands must not steal macOS app focus (no app activation/window raising side effects); only explicit focus-intent commands may mutate in-app focus/selection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5529 - 5534, The
focusSurface(tabId:surfaceId:) currently returns early for tmux-backed
workspaces and thus ignores explicit surface-focus requests; change it so that
when tab.isRealTmuxWorkspace is true you call selectRealTmuxPane(tabId: ,
surfaceId: ) to route the focus request to tmux and then update the local
selection (e.g., call tab.focusPanel(surfaceId) or otherwise sync the in-memory
focused panel) so the UI state reflects the change; ensure selectRealTmuxPane
and this flow do not trigger macOS app activation/window raising (only mutate
in-app selection), and keep focusTab(..., surfaceId:) callers functional after
import.

1246-1251: ⚠️ Potential issue | 🟠 Major

Missing-session cleanup still can't remove the only workspace in a window.

If the vanished tmux session maps to this window's last tab, closeWorkspace(..., killRealTmuxSession: false) immediately returns because tabs.count <= 1, so the stale imported workspace survives forever. This needs the same replacement-workspace fallback used in closeRealTmuxPanel(...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1246 - 1251, The cleanup loop that
calls closeWorkspace(sessionIdToWorkspaceId ...) skips closing if tabs.count <=
1, leaving a stale imported workspace; change the logic in the for-loop that
iterates (sessionId, workspaceId) to use the same replacement-workspace fallback
as closeRealTmuxPanel: when the workspace to close is found but tabs.count <= 1,
create/assign a replacement workspace (same behavior used by closeRealTmuxPanel)
and then proceed to remove the stale mapping and call closeWorkspace(...,
killRealTmuxSession: false) on the replacement or otherwise remove the workspace
so the stale imported workspace is not left behind. Ensure you reference and
reuse the same replacement creation/removal helpers used by closeRealTmuxPanel
to keep behavior consistent.

1254-1257: ⚠️ Potential issue | 🟠 Major

Don't prune tmux mappings for other windows.

This filter only keeps workspace ids from the current TabManager, so a sync in one window can delete sessionIdToWorkspaceId entries for live tmux workspaces owned by another window. Filter by live session ids only, or use the cross-window workspace-id set you already computed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1254 - 1257, The current filtering of
store.sessionIdToWorkspaceId uses refreshedWorkspaceIds (Set(tabs.map {
$0.id.uuidString })) which only contains workspace ids for this TabManager and
causes removal of tmux mappings belonging to other windows; change the filter to
only test live session membership (liveSessionIds.contains($0.key)) or use the
previously computed cross-window workspace-id set instead of
refreshedWorkspaceIds so you don't prune mappings for other windows—update the
closure on store.sessionIdToWorkspaceId.filter to reference liveSessionIds (or
the shared workspace-id set) rather than refreshedWorkspaceIds.

1259-1267: ⚠️ Potential issue | 🟠 Major

Avoid adopting a local workspace by title match.

The || $0.title == session.name fallback can silently convert an unrelated local workspace into a real-tmux workspace when names collide, after which close/focus/split start routing through tmux. Matching should stay limited to persisted mappings or existing realTmuxSessionId.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1259 - 1267, The code is incorrectly
matching local workspaces by title (the "|| $0.title == session.name" clause)
which can reassign unrelated workspaces to real tmux sessions; change the lookup
in the realSessions loop so you only adopt an existing workspace if
AppDelegate.shared?.mainWindowWorkspaceForRealTmuxSession(id:name:) returns one
or if a tab already has a matching realTmuxSessionId (tabs.first(where: {
$0.realTmuxSessionId == session.id })), removing the title-based fallback and
ensuring you only update store.sessionIdToWorkspaceId,
existing.realTmuxSessionId, and existing.realTmuxSessionName when a true
persisted or realTmuxSessionId match is found.
🧹 Nitpick comments (3)
Sources/TerminalController.swift (3)

5471-5471: Consider caching v2ResolveWindowId result.

v2ResolveWindowId(tabManager: tabManager) is called twice in the same expression. While unlikely to cause issues on the main thread, caching the result would be cleaner.

+            let windowId = v2ResolveWindowId(tabManager: tabManager)
             result = closed
-                ? .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
+                ? .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId)])
                 : .err(code: "internal_error", message: "Failed to close surface", data: ["surface_id": surfaceId.uuidString])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` at line 5471, The expression calls
v2ResolveWindowId(tabManager: tabManager) twice; compute it once into a local
(e.g., let resolvedWindowId = v2ResolveWindowId(tabManager: tabManager)) and
then use resolvedWindowId?.uuidString for the "window_id" value (wrapped with
v2OrNull if needed) and pass resolvedWindowId to v2Ref(kind: .window, uuid:
resolvedWindowId) for "window_ref" so the lookup is performed once and the
returned value reused.

16453-16455: Same inconsistency with inline computation.

Similar to hunk 4, this computes isRealTmuxWorkspace inline rather than using the property accessor. Consider using tab.isRealTmuxWorkspace for consistency with V2 split handlers.

-            let isRealTmuxWorkspace = tab.realTmuxSessionId != nil || tab.realTmuxSessionName != nil
-            // Don't close if it's the only surface, unless the surface represents a real tmux session.
-            if tab.panels.count <= 1 && !isRealTmuxWorkspace {
+            // Don't close if it's the only surface, unless the surface represents a real tmux session.
+            if tab.panels.count <= 1 && !tab.isRealTmuxWorkspace {

And update line 16461:

-            let closed = isRealTmuxWorkspace
+            let closed = tab.isRealTmuxWorkspace
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 16453 - 16455, Replace the
inline computation let isRealTmuxWorkspace = tab.realTmuxSessionId != nil ||
tab.realTmuxSessionName != nil with the property accessor
tab.isRealTmuxWorkspace and update any subsequent conditional that uses
isRealTmuxWorkspace (e.g. the if checking tab.panels.count <= 1 &&
!isRealTmuxWorkspace) to use tab.isRealTmuxWorkspace instead so the logic
matches the V2 split handlers; ensure you remove the local variable and
reference the existing tab.isRealTmuxWorkspace property wherever this inline
check was used.

5460-5461: Use property accessor for consistency.

Line 5460 computes isRealTmuxWorkspace inline with ws.realTmuxSessionId != nil || ws.realTmuxSessionName != nil. The ws.isRealTmuxWorkspace property accessor implements the same logic. Use the property accessor for consistency across the codebase:

-            let isRealTmuxWorkspace = ws.realTmuxSessionId != nil || ws.realTmuxSessionName != nil
-            if ws.panels.count <= 1 && !isRealTmuxWorkspace {
+            if ws.panels.count <= 1 && !ws.isRealTmuxWorkspace {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 5460 - 5461, Replace the
inline check that computes isRealTmuxWorkspace using ws.realTmuxSessionId != nil
|| ws.realTmuxSessionName != nil with the existing property accessor
ws.isRealTmuxWorkspace so the condition uses the canonical implementation;
update the variable usage in the block that currently defines let
isRealTmuxWorkspace = ... and the subsequent if to reference
ws.isRealTmuxWorkspace for consistency with the rest of the codebase.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/AppDelegate.swift`:
- Around line 3918-3927: The current mainWindowWorkspaceForRealTmuxSession
function is matching workspaces by title (title == sessionName) which can pick
non-tmux workspaces; change the predicate so it only matches real tmux sessions
by realTmuxSessionId. Either remove the title fallback entirely from the closure
in context.tabManager.tabs.first(where:) and only compare $0.realTmuxSessionId
== sessionId, or if you must keep a title-based fallback, restrict it to
workspaces that are already tmux-bound (e.g. only allow $0.title == sessionName
when $0.realTmuxSessionId != nil or another tmux-marker is present) so title
collisions won't select non-tmux workspaces.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 7191-7193: The early return in the key handling branch that checks
shouldBlockRealTmuxShortcut(event, surface: surface) swallows the tmux prefix
(Ctrl+B) on keyDown and breaks symmetry with keyUp; instead of returning, route
this case into the existing tmux forwarding path (the same flow used for
forwarding other tmux events) so the prefix is forwarded to the real tmux
workspace and keyUp behavior remains symmetrical—replace the return with a call
to the tmux-forwarding handler used elsewhere (or remove the block so execution
falls through to forwarding), keeping references to shouldBlockRealTmuxShortcut,
the current event, surface, and keyUp handling.

In `@Sources/RightSidebarPanelView.swift`:
- Around line 572-579: The prune-and-resolve logic uses tabManager.tabs (active
window only) causing active-window bias; change it to aggregate tabs from all
main-window contexts before computing liveWorkspaceIds and when resolving
existing workspaces for realSessions. Replace uses of tabManager.tabs with a
collectedTabs array built by querying the global window registry/main
controllers (e.g., allWindowControllers.flatMap { $0.tabs }) and use
collectedTabs in the Set creation and in the first(where:) lookups; apply the
same change to the other block referenced (lines 597-601) so pruning and
resolution search across all windows.
- Around line 673-676: realTmuxExecutablePath currently only checks a hardcoded
list of locations and misses tmux installs on MacPorts, Nix or custom PATHs;
update realTmuxExecutablePath to first search the user PATH (use
ProcessInfo.processInfo.environment["PATH"] split on ":" and check each
candidate with FileManager.default.isExecutableFile(atPath:)) and return the
first match, and only if that fails fall back to the existing hardcoded array
["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]; keep the
function nonisolated and preserve the FileManager checks so callers of
realTmuxExecutablePath see the same return semantics.
- Around line 515-519: The onKill handler currently calls
killRealTmuxSession(session.name) synchronously and immediately closes the
mapped workspace via tabManager.closeWorkspaceWithConfirmation(workspace);
change killRealTmuxSession to an asynchronous API that returns a success flag
(via completion handler, async/await, or Result) and do not await it on the main
thread; instead, kick off the kill asynchronously and only call
tabManager.closeWorkspaceWithConfirmation(workspace) after the kill completes
successfully (or after a follow-up refresh confirms the tmux session no longer
exists); update both occurrences (the onKill closure around
killRealTmuxSession(session.name) and the similar block at the later lines) to
use the new async success check so the UI button tap is non-blocking and the
workspace is only removed on confirmed kill.
- Around line 560-562: The tmux JSON disk I/O (calls to loadRealTmuxStore() and
saveRealTmuxStore()) must be moved off the main queue: perform those calls on a
background queue (e.g., DispatchQueue.global(qos:.utility).async) and only hop
back to DispatchQueue.main.async to call UI-updating code such as
applyRefresh(...) or to commit results to the view; alternatively, have
applyRefresh accept the already-loaded tmux store/result and call
saveRealTmuxStore from a background queue before/after UI updates. Ensure you
capture any returned data/error from loadRealTmuxStore() on the background
thread and pass it into applyRefresh on the main thread, and do not call
loadRealTmuxStore() or saveRealTmuxStore() directly from inside
DispatchQueue.main.async.

In `@Sources/TabManager.swift`:
- Around line 4941-4944: Currently the code unconditionally routes all panels in
a real tmux workspace through closeRealTmuxPanel by checking
tab.isRealTmuxWorkspace, which makes non-terminal/browser panels (which lack a
realTmuxPaneId) become no-ops when closeRealTmuxPanel returns false; change the
gate to check the specific panel’s realTmuxPaneId by calling
tab.terminalPanel(for: panelId)?.realTmuxPaneId and only call
closeRealTmuxPanel(tab: panelId:) when that optional pane id exists. Apply the
same fix to the other occurrence around the code near the 6096–6098 region so
only true tmux terminal panels are routed to closeRealTmuxPanel.
- Around line 5720-5727: In newSurface(initialInput:), when
workspace.isRealTmuxWorkspace is true the code currently calls
createSplit(tabId:surfaceId:direction:) and returns without forwarding
initialInput; modify the tmux branch to either (a) extend createSplit to accept
an optional initialInput parameter and pass initialInput through, or (b) capture
the created pane/surface id returned by createSplit and immediately call the
existing input-forwarding API (e.g. sendInputToSurface / writeToPane / whatever
central method your codebase uses) to write initialInput into the new surface;
ensure you reference selectedWorkspace, workspace.focusedPanelId and createSplit
in the change so the startup input is not dropped.
- Around line 1269-1283: The code marks workspaces as real-tmux even when there
is no initial pane proxy; change it so workspace.realTmuxSessionId,
workspace.realTmuxSessionName and store.sessionIdToWorkspaceId[session.id] are
only set when initialProxyCommand (computed via Self.realTmuxPaneProxyCommand
from initialPaneId) is non-nil. Leave addWorkspace call as-is but wrap the
assignments to workspace.realTmuxSessionId / realTmuxSessionName and the store
mapping in a conditional that checks initialProxyCommand != nil so imports
without a proxy are not treated as real-tmux.
- Around line 5529-5534: The focusSurface(tabId:surfaceId:) currently returns
early for tmux-backed workspaces and thus ignores explicit surface-focus
requests; change it so that when tab.isRealTmuxWorkspace is true you call
selectRealTmuxPane(tabId: , surfaceId: ) to route the focus request to tmux and
then update the local selection (e.g., call tab.focusPanel(surfaceId) or
otherwise sync the in-memory focused panel) so the UI state reflects the change;
ensure selectRealTmuxPane and this flow do not trigger macOS app
activation/window raising (only mutate in-app selection), and keep focusTab(...,
surfaceId:) callers functional after import.
- Around line 1246-1251: The cleanup loop that calls
closeWorkspace(sessionIdToWorkspaceId ...) skips closing if tabs.count <= 1,
leaving a stale imported workspace; change the logic in the for-loop that
iterates (sessionId, workspaceId) to use the same replacement-workspace fallback
as closeRealTmuxPanel: when the workspace to close is found but tabs.count <= 1,
create/assign a replacement workspace (same behavior used by closeRealTmuxPanel)
and then proceed to remove the stale mapping and call closeWorkspace(...,
killRealTmuxSession: false) on the replacement or otherwise remove the workspace
so the stale imported workspace is not left behind. Ensure you reference and
reuse the same replacement creation/removal helpers used by closeRealTmuxPanel
to keep behavior consistent.
- Around line 1254-1257: The current filtering of store.sessionIdToWorkspaceId
uses refreshedWorkspaceIds (Set(tabs.map { $0.id.uuidString })) which only
contains workspace ids for this TabManager and causes removal of tmux mappings
belonging to other windows; change the filter to only test live session
membership (liveSessionIds.contains($0.key)) or use the previously computed
cross-window workspace-id set instead of refreshedWorkspaceIds so you don't
prune mappings for other windows—update the closure on
store.sessionIdToWorkspaceId.filter to reference liveSessionIds (or the shared
workspace-id set) rather than refreshedWorkspaceIds.
- Around line 1259-1267: The code is incorrectly matching local workspaces by
title (the "|| $0.title == session.name" clause) which can reassign unrelated
workspaces to real tmux sessions; change the lookup in the realSessions loop so
you only adopt an existing workspace if
AppDelegate.shared?.mainWindowWorkspaceForRealTmuxSession(id:name:) returns one
or if a tab already has a matching realTmuxSessionId (tabs.first(where: {
$0.realTmuxSessionId == session.id })), removing the title-based fallback and
ensuring you only update store.sessionIdToWorkspaceId,
existing.realTmuxSessionId, and existing.realTmuxSessionName when a true
persisted or realTmuxSessionId match is found.

In `@Sources/Workspace.swift`:
- Around line 7206-7210: The snapshot serialization/deserialization needs to
include the real-Tmux identity and per-panel pane IDs: update the code paths
that create and restore SessionWorkspaceSnapshot to persist realTmuxSessionId
and realTmuxSessionName when isRealTmuxWorkspace is true, and update the
workspace rehydration logic (the code that reconstructs panels/TerminalPanel
state) to read and set each panel's realTmuxPaneId during restore; ensure the
snapshot model and the restore function that iterates panels both handle these
new fields so real-tmux workspaces are rehydrated with backing-pane routing
instead of falling back to local terminals.
- Around line 9875-9888: The newTerminalSurface(...) function (and any code
paths that call it like createTerminalToRight(...) and the context-menu creation
path) is still allowed to create a native cmux terminal in a real-tmux
workspace; add the same real-tmux invariant check at the top of
newTerminalSurface(...) (or make a single helper guard) so it returns nil unless
allowInRealTmuxWorkspace is true, and update callers (createTerminalToRight and
the context-menu terminal creation) to either pass allowInRealTmuxWorkspace when
appropriate or stop calling newTerminalSurface in real-tmux workspaces;
reference newTerminalSurface(...), createTerminalToRight(...), and the
context-menu creation path when making the change.

---

Nitpick comments:
In `@Sources/TerminalController.swift`:
- Line 5471: The expression calls v2ResolveWindowId(tabManager: tabManager)
twice; compute it once into a local (e.g., let resolvedWindowId =
v2ResolveWindowId(tabManager: tabManager)) and then use
resolvedWindowId?.uuidString for the "window_id" value (wrapped with v2OrNull if
needed) and pass resolvedWindowId to v2Ref(kind: .window, uuid:
resolvedWindowId) for "window_ref" so the lookup is performed once and the
returned value reused.
- Around line 16453-16455: Replace the inline computation let
isRealTmuxWorkspace = tab.realTmuxSessionId != nil || tab.realTmuxSessionName !=
nil with the property accessor tab.isRealTmuxWorkspace and update any subsequent
conditional that uses isRealTmuxWorkspace (e.g. the if checking tab.panels.count
<= 1 && !isRealTmuxWorkspace) to use tab.isRealTmuxWorkspace instead so the
logic matches the V2 split handlers; ensure you remove the local variable and
reference the existing tab.isRealTmuxWorkspace property wherever this inline
check was used.
- Around line 5460-5461: Replace the inline check that computes
isRealTmuxWorkspace using ws.realTmuxSessionId != nil || ws.realTmuxSessionName
!= nil with the existing property accessor ws.isRealTmuxWorkspace so the
condition uses the canonical implementation; update the variable usage in the
block that currently defines let isRealTmuxWorkspace = ... and the subsequent if
to reference ws.isRealTmuxWorkspace for consistency with the rest of the
codebase.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0744242d-ac96-4b23-967c-3d98ff998fee

📥 Commits

Reviewing files that changed from the base of the PR and between 51c5461 and 3f2258e.

📒 Files selected for processing (9)
  • CLI/cmux.swift
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/MainWindowFocusController.swift
  • Sources/RightSidebarPanelView.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • CLI/cmux.swift

@islee23520

Copy link
Copy Markdown
Author

Addressed the latest tmux review findings in 5f8ca7f, including cross-window session mapping, async/off-main sidebar tmux operations, stricter real tmux identity matching, tmux panel routing, snapshot persistence, and socket/key handling nits. @coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

@islee23520 Thanks for the update! I'll kick off a new review of the changes in commit 5f8ca7f4, covering cross-window session mapping, async/off-main sidebar tmux operations, stricter real tmux identity matching, tmux panel routing, snapshot persistence, and socket/key handling nits.


🧠 Learnings used
Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-05T21:26:10.710Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, applySessionPanelMetadata() must gate listeningPorts restoration on the per-panel snapshot.terminal?.isRemoteBacked flag (not workspace-wide remoteTerminalStartupCommand()), so that local panels are always eligible for port restore regardless of current SSH state. Fixed in commit 4d0fd871 (PR `#2545`).

Learnt from: MaTriXy
Repo: manaflow-ai/cmux PR: 1460
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-16T08:02:13.558Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, socket v2 methods that accept surface_id/panel_id must handle cross-window routing. For panel background state mutations, v2PanelMarkBackground(params:) and v2PanelMarkForeground(params:) first try v2ResolveTabManager(params:), but only accept it if that manager actually owns the panelId; otherwise they fall back to AppDelegate.shared?.locateSurface(surfaceId:) to find the correct TabManager across windows. This pattern should be used for any panel_id-only routes to avoid active-window bias.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-28T22:24:17.987Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift (PR `#3237`, commit 9dcb9c2), the `newTerminalSurface(...)` method gained an `allowInitialInputWithRemoteStartupCommand: Bool` parameter (default `false`). The session-restore call site in `createPanel(from:inPane:)` passes `true` only when `!panelWasRemoteBacked && combinedInitialInput != nil`, making the per-panel snapshot flag the sole authority for restore-time `initialInput` injection. All 12 other call sites keep `false`, preserving the existing remote-drop defense.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:679-709
Timestamp: 2026-04-05T20:54:35.429Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session restore, SessionTerminalPanelSnapshot now includes isRemoteBacked. createPanel(from:inPane:) uses this per-panel flag (panelWasRemoteBacked) to: (1) pass initialInput only for local panels, and (2) persist panelRestoreCommands only for local panels. Remote-backed terminals also omit restore/detected commands and have listeningPorts [] in the snapshot.

Learnt from: anthhub
Repo: manaflow-ai/cmux PR: 2239
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-27T06:49:12.578Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift (CMUXCLI.runClaudeHook stop/idle), workspace resolution uses selective do/catch and early-returns "OK" only on teardown-ignored errors; surface resolution failure no longer aborts the handler; setClaudeStatus(..., "Idle", ...) is always attempted after resolution; notification and session upsert only run when completion and surfaceId are present; teardown errors are filtered by shouldIgnoreClaudeHookTeardownError(_:) for graceful shutdown.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 3237
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-28T16:43:18.208Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session-restore path (createPanel(from:inPane:)), treat both agent resume input and auto-detected foreground command as "initialInput" and gate them by the per-panel snapshot.terminal?.isRemoteBacked flag. Specifically: compute panelWasRemoteBacked first, set restoredAgentResumeInput = nil when true, and also skip commandToRestore when true. Precedence remains restoredAgentResumeInput ?? commandToRestore for local panels.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2525
File: Sources/GhosttyTerminalView.swift:481-513
Timestamp: 2026-04-02T10:13:39.235Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, terminal file-link resolution trims trailing unmatched closing delimiters “) ] } >” only when they are dangling (more closers than openers), preserving wrapped tokens like “(file:///tmp/a.png)”. Implemented via terminalFileLinkTrailingClosingDelimiters and count comparison inside trimTrailingTerminalFileLinkPunctuation(_:) and exercised by a regression test (PR `#2525`, commit 3f5c5b6d).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2964
File: Sources/ContentView.swift:0-0
Timestamp: 2026-04-17T21:35:30.619Z
Learning: Repo: manaflow-ai/cmux — Session index cwd extraction: when computing SessionEntry.cwd (out.cwd), use the first non-empty cwd from the session’s head/launch events, not the last cwd seen later in the stream. This prevents tool-call cd’s from overriding the launch cwd used for resumes.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-28T22:24:17.987Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift (PR `#3237`, commit 9dcb9c2), the explicit-vs-baseConfig.initialInput resolution logic was extracted from `TerminalSurface.createSurface(for:)` into a standalone `TerminalSurface.resolveInitialInput(...)` static/instance method for unit-testability. Behavior is unchanged. A companion `TerminalSurfaceResolveInitialInputTests` suite asserts the contract, including byte-for-byte preservation of Ghostty raw-bytes startup-input via `baseConfig.initialInput`.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 3139
File: Sources/Panels/FilePreviewPanel.swift:534-537
Timestamp: 2026-04-28T05:45:34.719Z
Learning: Repo: manaflow-ai/cmux — Sources/Workspace.swift & Sources/Panels/FilePreviewPanel.swift (PR `#3139`): The shared close-confirmation gate in Workspace uses a generic `panel.isDirty` check (line ~8703) that applies to all panel types, including FilePreviewPanel. FilePreviewPanel exposes `Published private(set) var isDirty` and installFilePreviewPanelSubscription keeps the Bonsplit tab's isDirty flag in sync with it. All three teardown paths (tab close, pane collapse, workspace close) go through this gate, so no special-casing is needed for FilePreviewPanel's dirty state on teardown.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2662
File: Sources/TabManager.swift:1994-1995
Timestamp: 2026-04-07T05:27:49.028Z
Learning: Repo: manaflow-ai/cmux — Sources/TabManager.swift — In applyWorkspacePullRequestRefreshResults(...), resolution .unsupportedRepository must (1) clear any existing workspace.panelPullRequests[panelId] entry and (2) reset lastTerminalState timestamp. scheduleNextWorkspacePullRequestPoll(...) sets nextPollAt to a jittered background interval for .unsupportedRepository, avoiding hot-looping `git remote -v`. Intent: when a panel moves to a non-GitHub or otherwise unsupported repo, clear the sidebar PR badge and back off polling.

Learnt from: chi-feng
Repo: manaflow-ai/cmux PR: 2821
File: CLI/cmux.swift:2994-2998
Timestamp: 2026-04-11T18:54:44.886Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, v2 endpoints accept handle refs (e.g., "pane:<n>") for inputs like pane_id and resolve them to UUIDs via v2UUID → v2ResolveHandleRef. CLI commands (including markdown open --pane) can safely forward pane refs without pre-resolving to UUIDs, consistent with surface.create, pane.break, and surface.move.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

♻️ Duplicate comments (2)
Sources/GhosttyTerminalView.swift (1)

7191-7193: ⚠️ Potential issue | 🔴 Critical

Still swallowing tmux's default prefix in real-tmux workspaces.

This still turns Ctrl+B into a no-op by returning early on both key down and key up, so imported tmux sessions never receive the prefix sequence. Route the event through the tmux input path instead of dropping it.

Also applies to: 7569-7588, 7641-7643

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 7191 - 7193, The early return
in the key event handlers when shouldBlockRealTmuxShortcut(event, surface:
surface) is true is swallowing tmux's prefix (Ctrl+B); instead of returning,
forward the event into the tmux input path so the imported tmux session receives
it. Replace the bare return in the blocks that call shouldBlockRealTmuxShortcut
(seen in GhosttyTerminalView's key handling methods) with a call to the tmux
input forwarder (e.g., routeToTmuxInput(event, surface: surface) or
sendEventToTmuxInput(event, surface: surface)) so the event is delivered to
real-tmux workspaces; do the same for the other affected locations (the other
occurrences around the lines noted) to ensure both keyDown and keyUp are
forwarded.
Sources/TabManager.swift (1)

5538-5545: ⚠️ Potential issue | 🟠 Major

Non-tmux panels in tmux workspaces still no-op on explicit focus.

tab.isRealTmuxWorkspace is too broad here. If surfaceId is a browser panel, selectRealTmuxPane(...) returns false and the explicit focus request is dropped instead of falling back to tab.focusPanel(surfaceId).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5538 - 5545, The current
focusSurface(tabId: UUID, surfaceId: UUID) path treats any real tmux workspace
as exclusive, dropping focus requests when Self.selectRealTmuxPane(in:tab,
panelId:surfaceId) returns false; change the logic so we only call
selectRealTmuxPane for panes that are actually tmux panes (or, if
selectRealTmuxPane returns false, fall back to tab.focusPanel(surfaceId)).
Concretely: in focusSurface use a narrower condition (e.g., check the target
panel's type/metadata for tmux pane) before invoking
Self.selectRealTmuxPane(in:tab, panelId:surfaceId), and ensure that if
selectRealTmuxPane returns false (or the panel is not a tmux pane) you still
call tab.focusPanel(surfaceId) so non-tmux/browser panels in tmux workspaces are
focused.
🧹 Nitpick comments (1)
Sources/TerminalController.swift (1)

3119-3124: kind and source fields are currently identical.

Both fields return the same value ("real-tmux" or "native"). If this is intentional for API forward-compatibility, consider adding a brief comment. Otherwise, one field could be removed to reduce payload redundancy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 3119 - 3124, The JSON payload
sets both "kind" and "source" to the same value based on
workspace.isRealTmuxWorkspace, causing redundancy; either remove one of the
duplicate fields (e.g., drop "source" and keep "kind") or, if the duplication is
intentional for API forward-compatibility, add an inline comment next to the
dictionary entry explaining that duplication for future-proofing. Update any
serialization/consumer code that expects the removed field and keep the
real_tmux block (realTmuxSessionId/realTmuxSessionName) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/RightSidebarPanelView.swift`:
- Around line 487-500: The project is missing six localization keys used by
RightSidebarPanelView (Text and help strings) which prevents Japanese
translations from working; add entries for tmux.sessions.title,
tmux.sessions.refresh, tmux.session.current, tmux.session.attach,
tmux.session.kill, and rightSidebar.mode.tmux to Resources/Localizable.xcstrings
with English and Japanese translations (provide the English default and the
Japanese localized string for each key), ensure the keys match exactly the
identifiers used in the String(localized:defaultValue:) calls (e.g., usages in
RightSidebarPanelView and any session action UI), save the updated .xcstrings
files in the appropriate localization folder and rebuild so the localized
strings are picked up.

In `@Sources/TabManager.swift`:
- Around line 5912-5929: When tab.isRealTmuxWorkspace is true and you create a
backend tmux pane, ensure you roll it back if creating the local panel fails:
after obtaining realTmuxPaneId and proxyCommand, call tab.newTerminalSplit(...)
and if it returns nil then invoke Self.runRealTmux(arguments: ["kill-pane",
"-t", realTmuxPaneId]) to remove the orphaned tmux pane and then return nil;
reuse the existing guard that kills the pane on proxy-command failure as a
template and apply the same cleanup when newTerminalSplit returns nil.
- Around line 1209-1235: The probe currently conflates “no sessions” with probe
failure because listRealTmuxSessions() returns an empty array on error; change
the probe API (e.g., make listRealTmuxSessions() return Optional/[Result]/throw)
and update syncRealTmuxSessions() to detect probe failure before calling
applyRealTmuxSessionSync(sessionDetails:); when the probe fails, skip calling
applyRealTmuxSessionSync and avoid pruning/closing mapped workspaces (do not
treat empty result from a failed probe as authoritative), while preserving the
existing retry/rerun semantics (realTmuxSessionSyncInFlight and
realTmuxSessionSyncRerunPending). Ensure the same fix is applied for the
analogous block around applyRealTmuxSessionSync at the later region (the
1308–1329 area).
- Around line 1258-1284: The import path creates a workspace when
initialProxyCommand is nil but does not record
store.sessionIdToWorkspaceId[session.id], causing the same session to be
re-imported on the next sync; update the logic in the loop that calls
addWorkspace (and uses Self.realTmuxPaneProxyCommand / Self.realTmuxAttachInput)
so that after creating the workspace you always persist
store.sessionIdToWorkspaceId[session.id] = workspace.id.uuidString (regardless
of whether initialProxyCommand is nil), and only set workspace.realTmuxSessionId
/ workspace.realTmuxSessionName when initialProxyCommand is non-nil; this
prevents duplicate imports for non-proxied attach paths while preserving
proxy-specific fields.
- Around line 4949-4953: The early return path that calls
closeRealTmuxPanel(...) bypasses the normal confirm-close gate; update the close
flow so that when tab.isRealTmuxWorkspace and Self.normalizedRealTmuxPaneId(...)
!= nil you first invoke the same confirmation/close routine used for regular
panels (the method that handles running-process/dirty confirmation — e.g., the
existing closePanel/confirm-close flow) and only call closeRealTmuxPanel(...)
after confirmation succeeds (or have the normal close routine call
closeRealTmuxPanel as the final teardown), ensuring terminalPanel(for:),
normalizedRealTmuxPaneId(_:), and closeRealTmuxPanel(...) are used but do not
short-circuit the confirmation logic.
- Around line 4500-4510: The last-window close path isn't running the same
real-tmux-store cleanup in closeWorkspace(_:, killRealTmuxSession:), leaving
sessionIdToWorkspaceId entries pointing at dead workspaces; extract the cleanup
logic (the loadRealTmuxStore/filter/save sequence and optional
Self.killRealTmuxSession call) into a small helper like
cleanupRealTmuxMapping(for workspace:killSession:) and invoke that helper from
both closeWorkspace(...) and the code path that directly closes the last window
(or simply call closeWorkspace(...) from the last-window close path), ensuring
you reference and reuse the symbols CloseWorkspace/cleanupRealTmuxMapping,
Self.loadRealTmuxStore, sessionIdToWorkspaceId, Self.saveRealTmuxStore, and
Self.killRealTmuxSession so the stale mapping is removed in all code paths.

In `@Sources/Workspace.swift`:
- Around line 7228-7232: sidebarImmediateObservationPublisher and
sidebarObservationPublisher never observe changes to the `@Published` real-tmux
identity properties, so tmux badge/state can remain stale; update the publisher
arrays in sidebarImmediateObservationPublisher and sidebarObservationPublisher
to include observation signals for $realTmuxSessionId and $realTmuxSessionName
by adding sidebarObservationSignal($realTmuxSessionId) and
sidebarObservationSignal($realTmuxSessionName) to the publishers passed into
Publishers.MergeMany (using the existing sidebarObservationSignal helper) so
those property changes propagate to observers.
- Around line 741-750: Compute the proxy command up front using
Self.realTmuxPaneProxyCommand(snapshot.realTmuxPaneId) and if
snapshot.realTmuxPaneId is non-nil but the proxy command is nil, abort the
restore (do not call newTerminalSurface); otherwise pass that non-optional proxy
command into the initialCommand parameter and set allowInRealTmuxWorkspace only
when both realTmuxPaneId and the proxy command are present. This ensures
newTerminalSurface(...) is only created when the proxy command could be built
and prevents a native cmux terminal from being restored into a real-tmux
workspace.
- Around line 741-750: This call to newTerminalSurface is reconnecting to an
existing tmux pane (realTmuxPaneId != nil) and must not perform restore-time
replay; change the logic so that when realTmuxPaneId is non-nil you do not pass
restoredAgentResumeInput, replayEnvironment, or any restore initialCommand into
newTerminalSurface. Compute conditional values (e.g. let initialCommand =
realTmuxPaneId == nil ? realTmuxPaneProxyCommand(...) : nil; let initialInput =
realTmuxPaneId == nil ? restoredAgentResumeInput : nil; let startupEnvironment =
realTmuxPaneId == nil ? replayEnvironment : nil) and pass those variables into
newTerminalSurface so live tmux reattachments skip replay while keeping
allowInRealTmuxWorkspace and realTmuxPaneId as-is.

---

Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 7191-7193: The early return in the key event handlers when
shouldBlockRealTmuxShortcut(event, surface: surface) is true is swallowing
tmux's prefix (Ctrl+B); instead of returning, forward the event into the tmux
input path so the imported tmux session receives it. Replace the bare return in
the blocks that call shouldBlockRealTmuxShortcut (seen in GhosttyTerminalView's
key handling methods) with a call to the tmux input forwarder (e.g.,
routeToTmuxInput(event, surface: surface) or sendEventToTmuxInput(event,
surface: surface)) so the event is delivered to real-tmux workspaces; do the
same for the other affected locations (the other occurrences around the lines
noted) to ensure both keyDown and keyUp are forwarded.

In `@Sources/TabManager.swift`:
- Around line 5538-5545: The current focusSurface(tabId: UUID, surfaceId: UUID)
path treats any real tmux workspace as exclusive, dropping focus requests when
Self.selectRealTmuxPane(in:tab, panelId:surfaceId) returns false; change the
logic so we only call selectRealTmuxPane for panes that are actually tmux panes
(or, if selectRealTmuxPane returns false, fall back to
tab.focusPanel(surfaceId)). Concretely: in focusSurface use a narrower condition
(e.g., check the target panel's type/metadata for tmux pane) before invoking
Self.selectRealTmuxPane(in:tab, panelId:surfaceId), and ensure that if
selectRealTmuxPane returns false (or the panel is not a tmux pane) you still
call tab.focusPanel(surfaceId) so non-tmux/browser panels in tmux workspaces are
focused.

---

Nitpick comments:
In `@Sources/TerminalController.swift`:
- Around line 3119-3124: The JSON payload sets both "kind" and "source" to the
same value based on workspace.isRealTmuxWorkspace, causing redundancy; either
remove one of the duplicate fields (e.g., drop "source" and keep "kind") or, if
the duplication is intentional for API forward-compatibility, add an inline
comment next to the dictionary entry explaining that duplication for
future-proofing. Update any serialization/consumer code that expects the removed
field and keep the real_tmux block (realTmuxSessionId/realTmuxSessionName)
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7c30031b-b78d-49fb-bbb6-881804f0550f

📥 Commits

Reviewing files that changed from the base of the PR and between 3f2258e and 5f8ca7f.

📒 Files selected for processing (7)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/RightSidebarPanelView.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift

Comment thread Sources/RightSidebarPanelView.swift
Comment thread Sources/TabManager.swift
Comment thread Sources/TabManager.swift
Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/TabManager.swift
Comment thread Sources/TabManager.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift
@islee23520

Copy link
Copy Markdown
Author

Addressed the latest inline review findings in 6ae8abb: added missing tmux sidebar localizations, hardened real tmux probe/import/close/split rollback lifecycle, updated sidebar observation/restore behavior, and documented the kind/source socket compatibility field. Kept Ctrl+B blocking unchanged because it is an explicit product requirement for imported real-tmux workspaces. @coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

@islee23520 Thanks for the update! I'll kick off a new review of the changes in commit 6ae8abbc, covering the tmux sidebar localizations, hardened real tmux probe/import/close/split rollback lifecycle, sidebar observation/restore behavior, and the kind/source socket compatibility field documentation. I've noted that Ctrl+B blocking is an explicit product requirement for imported real-tmux workspaces.


✏️ Learnings added
Learnt from: islee23520
URL: https://github.com/manaflow-ai/cmux/pull/3275

Timestamp: 2026-04-29T04:20:21.104Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift (PR `#3275`), blocking Ctrl+B (the tmux prefix key) is an explicit product requirement for workspaces imported as real-tmux workspaces (i.e., where `realTmuxSessionId` is non-nil). The `shouldBlockRealTmuxShortcut` helper suppresses both keyDown and keyUp events for Ctrl+B when the surface's workspace is a real-tmux workspace, preventing the prefix from being sent to the underlying Ghostty terminal. This behavior must not be removed or gated further.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-05T21:26:10.710Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, applySessionPanelMetadata() must gate listeningPorts restoration on the per-panel snapshot.terminal?.isRemoteBacked flag (not workspace-wide remoteTerminalStartupCommand()), so that local panels are always eligible for port restore regardless of current SSH state. Fixed in commit 4d0fd871 (PR `#2545`).

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-28T22:24:17.987Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift (PR `#3237`, commit 9dcb9c2), the `newTerminalSurface(...)` method gained an `allowInitialInputWithRemoteStartupCommand: Bool` parameter (default `false`). The session-restore call site in `createPanel(from:inPane:)` passes `true` only when `!panelWasRemoteBacked && combinedInitialInput != nil`, making the per-panel snapshot flag the sole authority for restore-time `initialInput` injection. All 12 other call sites keep `false`, preserving the existing remote-drop defense.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:679-709
Timestamp: 2026-04-05T20:54:35.429Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session restore, SessionTerminalPanelSnapshot now includes isRemoteBacked. createPanel(from:inPane:) uses this per-panel flag (panelWasRemoteBacked) to: (1) pass initialInput only for local panels, and (2) persist panelRestoreCommands only for local panels. Remote-backed terminals also omit restore/detected commands and have listeningPorts [] in the snapshot.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 3237
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-28T16:43:18.208Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session-restore path (createPanel(from:inPane:)), treat both agent resume input and auto-detected foreground command as "initialInput" and gate them by the per-panel snapshot.terminal?.isRemoteBacked flag. Specifically: compute panelWasRemoteBacked first, set restoredAgentResumeInput = nil when true, and also skip commandToRestore when true. Precedence remains restoredAgentResumeInput ?? commandToRestore for local panels.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-28T22:24:17.987Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift (PR `#3237`, commit 9dcb9c2), the explicit-vs-baseConfig.initialInput resolution logic was extracted from `TerminalSurface.createSurface(for:)` into a standalone `TerminalSurface.resolveInitialInput(...)` static/instance method for unit-testability. Behavior is unchanged. A companion `TerminalSurfaceResolveInitialInputTests` suite asserts the contract, including byte-for-byte preservation of Ghostty raw-bytes startup-input via `baseConfig.initialInput`.

Learnt from: MaTriXy
Repo: manaflow-ai/cmux PR: 1460
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-16T08:02:13.558Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, socket v2 methods that accept surface_id/panel_id must handle cross-window routing. For panel background state mutations, v2PanelMarkBackground(params:) and v2PanelMarkForeground(params:) first try v2ResolveTabManager(params:), but only accept it if that manager actually owns the panelId; otherwise they fall back to AppDelegate.shared?.locateSurface(surfaceId:) to find the correct TabManager across windows. This pattern should be used for any panel_id-only routes to avoid active-window bias.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-06T18:23:54.205Z
Learning: Repo: manaflow-ai/cmux — Sources/Workspace.swift: When promoting an existing local terminal to remote-backed in Workspace.seedInitialRemoteTerminalSessionIfNeeded(_), clear both surfaceTTYNames[panelId] and surfaceListeningPorts[panelId] before calling trackRemoteTerminalSurface(_:) to avoid stale local TTY/port metadata in the workspace UI.

Learnt from: anthhub
Repo: manaflow-ai/cmux PR: 2239
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-27T06:49:12.578Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift (CMUXCLI.runClaudeHook stop/idle), workspace resolution uses selective do/catch and early-returns "OK" only on teardown-ignored errors; surface resolution failure no longer aborts the handler; setClaudeStatus(..., "Idle", ...) is always attempted after resolution; notification and session upsert only run when completion and surfaceId are present; teardown errors are filtered by shouldIgnoreClaudeHookTeardownError(_:) for graceful shutdown.

Learnt from: atani
Repo: manaflow-ai/cmux PR: 819
File: Sources/AppDelegate.swift:3491-3493
Timestamp: 2026-03-04T14:06:16.241Z
Learning: For manaflow-ai/cmux PR `#819` (Japanese i18n), keep scope limited to localization changes; UX enhancements like preferring workspace.customTitle in workspaceDisplayName() or altering move-target labels should be handled in a separate follow-up issue.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 3247
File: Sources/ContentView.swift:0-0
Timestamp: 2026-04-29T01:08:39.652Z
Learning: Repo: manaflow-ai/cmux — Behavior contract: Sidebar “Copy Workspace ID(s)” (e.g., in Sources/ContentView.swift TabItemView context menu) must copy plain UUIDs (IDs-only), not refs. Command palette identifier-copy commands should explicitly pass includeRefs: true when refs are desired. This split preserves backward compatibility for scripts while enabling richer payloads in the palette.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-01T09:50:23.728Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift within CMUXCLI.buildInteractiveRemoteShellScript(...), never export CMUX_TAB_ID from the workspace UUID. CMUX_TAB_ID must be surface-scoped: only set it when a surface ID is available (map CMUX_TAB_ID to CMUX_SURFACE_ID). Rationale: tab-action/rename-tab resolve CMUX_TAB_ID before CMUX_SURFACE_ID; workspace-scoped values misroute or fail.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 3139
File: Sources/Panels/FilePreviewPanel.swift:534-537
Timestamp: 2026-04-28T05:45:34.719Z
Learning: Repo: manaflow-ai/cmux — Sources/Workspace.swift & Sources/Panels/FilePreviewPanel.swift (PR `#3139`): The shared close-confirmation gate in Workspace uses a generic `panel.isDirty` check (line ~8703) that applies to all panel types, including FilePreviewPanel. FilePreviewPanel exposes `Published private(set) var isDirty` and installFilePreviewPanelSubscription keeps the Bonsplit tab's isDirty flag in sync with it. All three teardown paths (tab close, pane collapse, workspace close) go through this gate, so no special-casing is needed for FilePreviewPanel's dirty state on teardown.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2662
File: Sources/TabManager.swift:1994-1995
Timestamp: 2026-04-07T05:27:49.028Z
Learning: Repo: manaflow-ai/cmux — Sources/TabManager.swift — In applyWorkspacePullRequestRefreshResults(...), resolution .unsupportedRepository must (1) clear any existing workspace.panelPullRequests[panelId] entry and (2) reset lastTerminalState timestamp. scheduleNextWorkspacePullRequestPoll(...) sets nextPollAt to a jittered background interval for .unsupportedRepository, avoiding hot-looping `git remote -v`. Intent: when a panel moves to a non-GitHub or otherwise unsupported repo, clear the sidebar PR badge and back off polling.

Learnt from: chi-feng
Repo: manaflow-ai/cmux PR: 2821
File: CLI/cmux.swift:2994-2998
Timestamp: 2026-04-11T18:54:44.886Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, v2 endpoints accept handle refs (e.g., "pane:<n>") for inputs like pane_id and resolve them to UUIDs via v2UUID → v2ResolveHandleRef. CLI commands (including markdown open --pane) can safely forward pane refs without pre-resolving to UUIDs, consistent with surface.create, pane.break, and surface.move.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
Sources/TerminalController.swift (1)

14974-14977: ⚠️ Potential issue | 🟠 Major

Still return an explicit error when newSplit fails.

This branch still exits without setting result when tabManager.newSplit(...) returns nil, so the V1 caller can get an empty/stale response instead of a failure string.

🐛 Proposed fix
                 if let id = tabManager.newSplit(tabId: tabId, surfaceId: focusedPanelId, direction: direction, focus: focus) {
                     result = "OK \(id.uuidString) [tmux]"
+                } else {
+                    result = "ERROR: Failed to split real tmux pane"
                 }
                 return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 14974 - 14977, The branch
handling the result of tabManager.newSplit(...) currently returns early without
setting result when newSplit returns nil; update the logic around the call to
tabManager.newSplit(tabId: tabId, surfaceId: focusedPanelId, direction:
direction, focus: focus) so that when it returns nil you set result to an
explicit error string (e.g., "ERR newSplit failed" or similar) before returning,
while preserving the existing success path that sets result = "OK
\(id.uuidString) [tmux]".
Sources/TabManager.swift (2)

1290-1308: ⚠️ Potential issue | 🟠 Major

Don't re-promote attach-fallback imports into real-tmux mode on the next poll.

This path correctly creates a fallback import with no realTmuxPaneId when proxy startup is unavailable, but the unconditional assignment at Lines 1307-1308 flips that workspace back into isRealTmuxWorkspace on the next sync anyway. At that point the existing terminal panel is still not pane-backed, so close/split/focus routing becomes inconsistent again. Only set realTmuxSessionId/realTmuxSessionName once the workspace already owns a pane-backed proxy panel.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1290 - 1308, The loop that syncs
realSessions unconditionally assigns workspace.realTmuxSessionId/Name (symbols:
realSessions loop, workspace.realTmuxSessionId, workspace.realTmuxSessionName),
which re-promotes attach-fallback imports into real-tmux mode; change it so
those two properties are only set when the workspace actually owns a pane-backed
proxy panel — e.g. check for an existing pane-backed proxy on the workspace
(such as workspace.proxyPanel?.isPaneBacked or a helper like
workspace.ownsPaneBackedProxy()) and only then assign
realTmuxSessionId/realTmuxSessionName; leave them unset when the workspace has a
fallback import without a pane-backed proxy.

1216-1227: ⚠️ Potential issue | 🟠 Major

Treat “no tmux server” as an authoritative empty result, not a probe failure.

When the last tmux session exits, tmux list-sessions returns a non-zero status. listRealTmuxSessions() currently maps that to nil, and syncRealTmuxSessions() treats nil as “skip reconciliation”, so the stale real-tmux workspaces never get closed/pruned. This needs a third state: “probe failed” vs “tmux has no live sessions”.

Also applies to: 1318-1340

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1216 - 1227, listRealTmuxSessions
currently conflates "no tmux server / zero sessions" with a probe failure by
returning nil; update the API and callers so you can distinguish three states:
probe error, authoritative empty result, and non-empty sessions. Change
listRealTmuxSessions to return a discriminated result (e.g., enum or Result
type) that encodes .error(Error), .empty, and .sessions([String]) and update
syncRealTmuxSessions (and the DispatchQueue block using
realTmuxSessionSyncInFlight/realTmuxSessionSyncRerunPending) to treat .empty as
an authoritative set (perform reconciliation/closure of stale workspaces) while
only skipping reconciliation on .error. Ensure all call sites that currently
check for nil are updated to handle the new cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/TabManager.swift`:
- Around line 4511-4524: The cleanupRealTmuxMapping(for:killSession:) call is
currently executed before the early return guard in
closeWorkspace(_:killRealTmuxSession:), which can remove/kill the tmux backend
while leaving the last workspace onscreen; move the call so that the guard
tabs.count > 1 runs first and only if we will actually close a workspace call
cleanupRealTmuxMapping(for: workspace, killSession: killRealTmuxSession). Update
closeWorkspace(_:killRealTmuxSession:) to perform the guard check before
invoking cleanupRealTmuxMapping and ensure the same function signature and
semantics are preserved.

In `@Sources/TerminalController.swift`:
- Around line 5462-5474: The check/branch is using the workspace-wide flag
isRealTmuxWorkspace but the operation is per-surface; instead use the surface's
tmux identity (realTmuxPaneId or equivalent) to decide routing and last-surface
gating. Change the predicate around TabManager.closeSurface(tabId: ws.id,
surfaceId: surfaceId) and ws.closePanel(surfaceId, force: true) to test the
target panel’s realTmuxPaneId (or a helper that returns whether the specific
surface is tmux-backed) rather than ws.isRealTmuxWorkspace, and ensure the
"cannot close last surface" guard also uses that per-surface predicate; keep
TabManager.closeSurface and ws.closePanel calls intact and leave
v2ResolveWindowId, surfaceId and ws.id usages as-is.

In `@Sources/Workspace.swift`:
- Around line 328-329: The code is enabling "real-tmux" behavior based solely on
isRealTmuxWorkspace (which can be true when only a display name exists); change
those conditionals to require a non-nil realTmuxSessionId instead. Update
occurrences where realTmuxSessionId/realTmuxSessionName are passed (e.g., the
initializer call with realTmuxSessionId: and realTmuxSessionName:) to use
realTmuxSessionId != nil as the gate, leave realTmuxSessionName as display-only
metadata, and likewise change any logic in GhosttyTerminalView and the Ctrl+B
blocking code paths (the blocks around lines ~7238-7242) to only activate when
realTmuxSessionId != nil.
- Around line 9917-9925: The session-drop path bypasses the real-tmux guard
because handleSessionDrop calls splitPaneWithNewTerminal which directly
instantiates TerminalPanel; add the same
isRealTmuxWorkspace/allowInRealTmuxWorkspace check before creating a native
terminal in that path. Concretely, update handleSessionDrop and/or
splitPaneWithNewTerminal to early-return (nil or no-op) when isRealTmuxWorkspace
&& !allowInRealTmuxWorkspace, mirroring the guard used in
newTerminalSplit/newTerminalSurface so TerminalPanel is never instantiated for
real-tmux workspaces.

---

Duplicate comments:
In `@Sources/TabManager.swift`:
- Around line 1290-1308: The loop that syncs realSessions unconditionally
assigns workspace.realTmuxSessionId/Name (symbols: realSessions loop,
workspace.realTmuxSessionId, workspace.realTmuxSessionName), which re-promotes
attach-fallback imports into real-tmux mode; change it so those two properties
are only set when the workspace actually owns a pane-backed proxy panel — e.g.
check for an existing pane-backed proxy on the workspace (such as
workspace.proxyPanel?.isPaneBacked or a helper like
workspace.ownsPaneBackedProxy()) and only then assign
realTmuxSessionId/realTmuxSessionName; leave them unset when the workspace has a
fallback import without a pane-backed proxy.
- Around line 1216-1227: listRealTmuxSessions currently conflates "no tmux
server / zero sessions" with a probe failure by returning nil; update the API
and callers so you can distinguish three states: probe error, authoritative
empty result, and non-empty sessions. Change listRealTmuxSessions to return a
discriminated result (e.g., enum or Result type) that encodes .error(Error),
.empty, and .sessions([String]) and update syncRealTmuxSessions (and the
DispatchQueue block using
realTmuxSessionSyncInFlight/realTmuxSessionSyncRerunPending) to treat .empty as
an authoritative set (perform reconciliation/closure of stale workspaces) while
only skipping reconciliation on .error. Ensure all call sites that currently
check for nil are updated to handle the new cases.

In `@Sources/TerminalController.swift`:
- Around line 14974-14977: The branch handling the result of
tabManager.newSplit(...) currently returns early without setting result when
newSplit returns nil; update the logic around the call to
tabManager.newSplit(tabId: tabId, surfaceId: focusedPanelId, direction:
direction, focus: focus) so that when it returns nil you set result to an
explicit error string (e.g., "ERR newSplit failed" or similar) before returning,
while preserving the existing success path that sets result = "OK
\(id.uuidString) [tmux]".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 51e9639d-0b95-407f-8c48-2b1efeaf4a11

📥 Commits

Reviewing files that changed from the base of the PR and between 5f8ca7f and 6ae8abb.

📒 Files selected for processing (4)
  • Resources/Localizable.xcstrings
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
✅ Files skipped from review due to trivial changes (1)
  • Resources/Localizable.xcstrings

Comment thread Sources/TabManager.swift
Comment on lines +4511 to 4524
private func cleanupRealTmuxMapping(for workspace: Workspace, killSession: Bool) {
if let realTmuxSessionId = workspace.realTmuxSessionId {
var store = Self.loadRealTmuxStore()
store.sessionIdToWorkspaceId = store.sessionIdToWorkspaceId.filter { $0.value != workspace.id.uuidString }
Self.saveRealTmuxStore(store)
if killSession {
Self.killRealTmuxSession(id: realTmuxSessionId, name: workspace.realTmuxSessionName)
}
}
}

func closeWorkspace(_ workspace: Workspace, killRealTmuxSession: Bool = true) {
cleanupRealTmuxMapping(for: workspace, killSession: killRealTmuxSession)
guard tabs.count > 1 else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Move tmux cleanup below the last-workspace guard.

cleanupRealTmuxMapping(for:killSession:) now runs before guard tabs.count > 1, so closeWorkspace(...) can remove the mapping and kill the backing tmux session even when the method immediately bails out and leaves the last workspace open. closeCurrentWorkspace()/closeTab(_:) still call this path directly, so this can strand an onscreen workspace with its backend already destroyed.

♻️ Suggested fix
 func closeWorkspace(_ workspace: Workspace, killRealTmuxSession: Bool = true) {
-    cleanupRealTmuxMapping(for: workspace, killSession: killRealTmuxSession)
     guard tabs.count > 1 else { return }
+    cleanupRealTmuxMapping(for: workspace, killSession: killRealTmuxSession)
     sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
     clearWorkspaceGitProbes(workspaceId: workspace.id)
     clearWorkspacePullRequestTracking(workspaceId: workspace.id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 4511 - 4524, The
cleanupRealTmuxMapping(for:killSession:) call is currently executed before the
early return guard in closeWorkspace(_:killRealTmuxSession:), which can
remove/kill the tmux backend while leaving the last workspace onscreen; move the
call so that the guard tabs.count > 1 runs first and only if we will actually
close a workspace call cleanupRealTmuxMapping(for: workspace, killSession:
killRealTmuxSession). Update closeWorkspace(_:killRealTmuxSession:) to perform
the guard check before invoking cleanupRealTmuxMapping and ensure the same
function signature and semantics are preserved.

Comment on lines +5462 to +5474
if ws.panels.count <= 1 && !ws.isRealTmuxWorkspace {
result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil)
return
}

// Socket API must be non-interactive: bypass close-confirmation gating.
ws.closePanel(surfaceId, force: true)
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
let closed = ws.isRealTmuxWorkspace
? tabManager.closeSurface(tabId: ws.id, surfaceId: surfaceId)
: ws.closePanel(surfaceId, force: true)
let resolvedWindowId = v2ResolveWindowId(tabManager: tabManager)
result = closed
? .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(resolvedWindowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: resolvedWindowId)])
: .err(code: "internal_error", message: "Failed to close surface", data: ["surface_id": surfaceId.uuidString])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the target surface’s tmux identity here, not the workspace-wide flag.

These branches are keyed off isRealTmuxWorkspace, but the operation is surface-specific. That means a non-tmux surface inside a real-tmux workspace can bypass the last-surface guard or get routed into tmux split handling even when that panel is not tmux-backed. TabManager.closeSurface(...) already makes this decision per panel via realTmuxPaneId; these call sites should mirror that predicate.

Also applies to: 5500-5516, 6755-6777, 16455-16465

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 5462 - 5474, The check/branch
is using the workspace-wide flag isRealTmuxWorkspace but the operation is
per-surface; instead use the surface's tmux identity (realTmuxPaneId or
equivalent) to decide routing and last-surface gating. Change the predicate
around TabManager.closeSurface(tabId: ws.id, surfaceId: surfaceId) and
ws.closePanel(surfaceId, force: true) to test the target panel’s realTmuxPaneId
(or a helper that returns whether the specific surface is tmux-backed) rather
than ws.isRealTmuxWorkspace, and ensure the "cannot close last surface" guard
also uses that per-surface predicate; keep TabManager.closeSurface and
ws.closePanel calls intact and leave v2ResolveWindowId, surfaceId and ws.id
usages as-is.

Comment thread Sources/Workspace.swift
Comment on lines +328 to +329
realTmuxSessionId: isRealTmuxWorkspace ? realTmuxSessionId : nil,
realTmuxSessionName: isRealTmuxWorkspace ? realTmuxSessionName : nil,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Key real-tmux mode off realTmuxSessionId only.

isRealTmuxWorkspace currently becomes true when only the display name is present. That pushes partial/legacy snapshots into the tmux-only paths here and in GhosttyTerminalView, even though there is no stable tmux session identity to route against. Gate the mode on realTmuxSessionId != nil and treat realTmuxSessionName as display metadata only.

Suggested fix
     `@Published` var realTmuxSessionId: String?
     `@Published` var realTmuxSessionName: String?
     var isRealTmuxWorkspace: Bool {
-        realTmuxSessionId != nil || realTmuxSessionName != nil
+        realTmuxSessionId != nil
     }

Based on learnings, blocking Ctrl+B is only required for real-tmux workspaces “where realTmuxSessionId is non-nil”.

Also applies to: 7238-7242

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 328 - 329, The code is enabling
"real-tmux" behavior based solely on isRealTmuxWorkspace (which can be true when
only a display name exists); change those conditionals to require a non-nil
realTmuxSessionId instead. Update occurrences where
realTmuxSessionId/realTmuxSessionName are passed (e.g., the initializer call
with realTmuxSessionId: and realTmuxSessionName:) to use realTmuxSessionId !=
nil as the gate, leave realTmuxSessionName as display-only metadata, and
likewise change any logic in GhosttyTerminalView and the Ctrl+B blocking code
paths (the blocks around lines ~7238-7242) to only activate when
realTmuxSessionId != nil.

Comment thread Sources/Workspace.swift
@islee23520 islee23520 closed this Apr 29, 2026
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.

1 participant