Skip to content

Add tmux control mode integration - #1867

Open
jamesainslie wants to merge 11 commits into
manaflow-ai:mainfrom
jamesainslie:tmux-control-mode
Open

jamesainslie wants to merge 11 commits into
manaflow-ai:mainfrom
jamesainslie:tmux-control-mode

Conversation

@jamesainslie

@jamesainslie jamesainslie commented Mar 20, 2026 •

Copy link
Copy Markdown

Summary

  • Adds native tmux control mode (tmux -CC) integration, allowing cmux to act as a GUI frontend for tmux sessions
  • Implements full protocol bridge: Ghostty Zig Viewer handles DCS/protocol parsing, Swift layer manages native workspaces, splits, and pane routing
  • 10 commits across 8 implementation phases: protocol foundation, virtual surfaces, layout engine, input/output routing, resize sync, session persistence, UI integration, and advanced features (pause mode, version gates, timeout detection)
  • 40 unit tests across 6 test classes, all passing
  • Also fixes 6 pre-existing test compilation errors (broken #if/#endif, MainActor isolation, duplicate declarations)

New files

  • Sources/Tmux/TmuxController.swift — manages tmux connection lifecycle, entity maps, event dispatch
  • Sources/Tmux/TmuxGateway.swift — bridges Ghostty C API actions to controller, write gating
  • Sources/Tmux/TmuxTypes.swift — shared types (events, layout tree, capabilities, session info)
  • Sources/Tmux/TmuxKeyEncoder.swift — literal/hex keystroke encoding with batch limits
  • Sources/Tmux/TmuxLayoutEngine.swift — N-ary to binary layout conversion, layout diffing
  • Sources/Tmux/TmuxPaneClient.swift — virtual surface per tmux pane with Manual I/O
  • cmuxTests/TmuxTests.swift — comprehensive unit tests

Modified files

  • ghostty submodule — tmux embedder API (pane_output, windows, layout_change actions)
  • ghostty.h — C API types for tmux events
  • GhosttyTerminalView.swift — action handler for GHOSTTY_ACTION_TMUX_CONTROL, ManualIOConfig
  • Workspace.swift — tmuxControllerId, isBuriedGateway, session snapshot filtering
  • TabManager.swift — tmux workspace creation, reconnection, detach dialog
  • ContentView.swift — sidebar tmux badge (Equatable-safe)
  • SessionPersistence.swift — TmuxSessionInfo in workspace snapshots
  • cmuxApp.swift — tmux menu with Detach command
  • TerminalPanel.swift — isTmuxClient indicator

Test plan

  • 40 unit tests pass (TmuxKeyEncoder, TmuxLayoutEngine, TmuxTypes, TmuxCapabilities, SessionWorkspaceSnapshot)
  • Build compiles clean with CMUX_SKIP_ZIG_BUILD=1
  • Pre-existing test suite: 0 unexpected failures
  • E2E: tmux -CC in tagged build triggers gateway detection and workspace creation
  • E2E: typing in virtual pane round-trips through tmux
  • E2E: tmux split-window creates native split
  • E2E: quit + relaunch reconnects to tmux session

Summary by cubic

Adds native tmux control mode (tmux -CC) so cmux can act as a GUI for tmux. Implements a protocol bridge, virtual panes with manual I/O, native layouts, and session reconnect. Rebased on latest main and resolved test conflicts; no functional changes.

  • New Features

    • Control mode bridge: routes tmux events from the Ghostty Zig Viewer to Swift; manages windows, panes, and pane output.
    • Virtual panes: Manual I/O surfaces per tmux pane; keystrokes encoded and sent via send-keys; pane output rendered directly.
    • Layout sync: converts tmux N-ary layouts to native splits; creates/updates workspaces; resize sync with version gates.
    • Session persistence: saves TmuxSessionInfo, auto‑reconnects on launch, skips scrollback; gateway workspace is hidden; detach dialog and tmux menu (Ctrl+Cmd+D).
    • UI: tmux badge in the sidebar; tmux panes use a split icon; safe detach and reconnect handling; pause mode, timeout detection, and feedback‑loop prevention.
  • Dependencies

    • Updates ghostty submodule to a fork with tmux embedder C API: GHOSTTY_ACTION_TMUX_CONTROL, events (enter/exit/windows_changed/pane_output), and manual I/O support in ghostty.h.

Written for commit 5e711d8. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Added tmux control-mode integration enabling direct session and pane management
    • Tmux sessions now display with dedicated badges in the sidebar for easy identification
    • New "Detach" command (Ctrl+Cmd+D) to safely disconnect from active tmux sessions
    • Session persistence now restores tmux gateway workspaces on app restart
    • Terminal surfaces now support manual I/O mode for virtual pane handling

Update ghostty submodule to include tmux control mode C API:
- TmuxControl action with Event enum (enter/exit/windows_changed/pane_output/etc)
- JSON serialization for windows payload with recursive layout tree
- Surface message forwarding for pane_output, windows, exit events
- ghostty.h updated with tmux types and cmux fork additions

Points to jamesainslie/ghostty fork (PR manaflow-ai#16 to manaflow-ai/ghostty).
- TmuxTypes.swift: connection state enum, TmuxEvent with C parser,
  Codable types for windows payload and recursive layout tree
- TmuxGateway.swift: bridges C API actions to controller, write gating
  with command buffering, global dispatch routing per surface
- TmuxController.swift: manages tmux connection lifecycle and state,
  entity maps (window->workspace, pane->client), gateway burial
- Wire GHOSTTY_ACTION_TMUX_CONTROL in GhosttyTerminalView action handler
- Add isBuriedGateway flag to Workspace, filter sidebar to hide gateway
- TmuxPaneClient: creates Manual I/O TerminalSurface for tmux panes,
  feeds output via ghostty_surface_process_output, captures keystrokes
  via io_write_cb for forwarding to tmux server
- TerminalSurface.ManualIOConfig: configures io_mode/write callback
  during surface creation for surfaces without a local PTY
- TmuxController.routeOutput: routes pane output to correct client
- History loading deferred (requires Zig-side pane_output emission)
- TmuxLayoutEngine: converts N-ary tmux layout trees to binary trees
  via right-folding, computes divider fractions accounting for tmux's
  1-cell dividers, layout diffing for resize-only vs rebuild detection
- TmuxController.openWindows: creates workspace per tmux window,
  registers TmuxPaneClient for each pane in the layout tree
- TmuxController.removeWorkspaceForWindow: tears down pane clients
  and removes workspace on window close
- TmuxKeyEncoder: classifies bytes as literal ([a-zA-Z0-9+/):,_ ])
  or hex (everything else), batches with limits (1000 literal, 125 hex),
  generates tmux send-keys commands with -l flag for literals
- TmuxController.sendKeys: encodes keystroke data and sends each
  command through the gateway to the tmux server
- Mouse events use the same hex encoding path (escape sequences
  contain non-UTF-8 bytes)
- Debounced window resize (100ms) with version-appropriate commands:
  tmux >= 3.4 uses refresh-client -C, older uses resize-window
- Feedback loop prevention: outstandingResizeCount suppresses echoed
  layout changes from our own resize commands
- Window rename events propagate to workspace titles
- Proper pane client teardown on disconnect
- Clipboard sync deferred (lower priority)
Phase 6: Session Persistence + Reconnection
- Add TmuxSessionInfo Codable type for tmux session metadata
- Add tmuxSession field to SessionWorkspaceSnapshot
- Add tmuxControllerId back-reference on Workspace
- Filter buried gateways and deduplicate tmux workspaces in snapshots
- Skip terminal scrollback for tmux workspaces (tmux re-sends on reconnect)
- Auto-reconnect via tmux -CC attach on session restore
- Persist @cmux_id as tmux session option for double-attach detection

Phase 7: UI Integration
- Add tmux badge to sidebar TabItemView (with Equatable compliance)
- Add tmux menu with Detach command (Ctrl+Cmd+D)
- Add isTmuxClient property to TerminalPanel with distinct icon
- Add detach confirmation dialog when closing tmux workspaces
- TmuxController.hasActiveConnection and detachAll() static helpers
…Phase 8)

- Add TmuxCapabilities struct with version-gated feature detection
  (pause mode >= 3.2, variable window size >= 2.9, per-window refresh >= 3.4)
- Implement pause mode: auto-pause after 120s, 1s delay auto-unpause
  with capture-pane and refresh-client continue
- Add double-attach detection via @cmux_id session option check
- Add command timeout detection (5s) for unresponsive tmux servers
- Clean up all timers (resize debounce, pause, timeout) in teardown
- BrowserConfigTests: add missing #endif for #if compiler(>=6.2) block
- WindowAndDragTests: remove stray #endif, add missing #endif for #if DEBUG,
  move InternalTabDragConfigurationTests to BrowserConfigTests where private
  types are accessible
- WorkspaceUnitTests: remove stray #endif
- WorkspaceManualUnreadTests: add @mainactor, mark function throws
- AppDelegateShortcutRoutingTests: remove duplicate top-level declaration
- BrowserPanelTests: remove duplicate top-level declarations
40 tests across 6 test classes covering all pure/testable tmux components:
- TmuxKeyEncoderTests (13): literal/hex encoding, batch limits, mixed modes
- TmuxLayoutEngineTests (11): N-ary to binary conversion, right-folding, diffing
- TmuxTypesTests (9): Codable roundtrips, JSON decoding, layout nodes
- TmuxCapabilitiesTests (5): version-gated feature detection
- TmuxConnectionStateTests (1): raw value existence
- SessionWorkspaceSnapshotTmuxTests (2): backward compatibility, tmuxSession roundtrip
@vercel

vercel Bot commented Mar 20, 2026

Copy link
Copy Markdown

@jamesainslie 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 Mar 20, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces tmux control-mode integration with new infrastructure for managing tmux sessions and panes. Adds six Swift modules handling connection management, event routing, layout rendering, and key encoding. Updates existing UI components to display tmux-specific badges and status, with session persistence for reconnection.

Changes

Cohort / File(s) Summary
Tmux Core Infrastructure
Sources/Tmux/TmuxTypes.swift, Sources/Tmux/TmuxGateway.swift, Sources/Tmux/TmuxController.swift, Sources/Tmux/TmuxPaneClient.swift, Sources/Tmux/TmuxLayoutEngine.swift, Sources/Tmux/TmuxKeyEncoder.swift
New modules implementing tmux control-mode coordination: event types and connection states; gateway bridging C actions to controllers; central controller for session/connection lifecycle and window/pane management; virtual terminal clients for individual panes; layout tree conversion and diffing; key-to-tmux-command encoding.
Terminal Surface & Manual I/O
Sources/GhosttyTerminalView.swift
Added ManualIOConfig struct and manualIOMode property to TerminalSurface for tmux virtual pane support; added GHOSTTY_ACTION_TMUX_CONTROL handler dispatching to TmuxGateway.handleGlobalAction.
UI & Sidebar Updates
Sources/ContentView.swift, Sources/Panels/TerminalPanel.swift
Filtered sidebar to exclude buried gateway workspaces; added tmux badge display and isTmuxWorkspace tracking in tab items; updated TerminalPanel to compute isTmuxClient and display tmux-specific icon.
Session & Workspace Management
Sources/Workspace.swift, Sources/TabManager.swift, Sources/SessionPersistence.swift
Added tmuxControllerId and isBuriedGateway to workspaces; added special-case detach dialog for connected tmux workspaces in closeWorkspaceWithConfirmation; refactored session snapshot filtering to exclude buried gateways and deduplicate tmux controllers; refactored restore to create gateway workspaces per tmux reconnect command instead of individual panes; added tmuxSession field to SessionWorkspaceSnapshot for persistence.
App Integration & Commands
Sources/cmuxApp.swift
Added tmux command menu with "Detach All" action and conditional keyboard shortcut Ctrl+Cmd+D.
Build & Project Configuration
.gitmodules, GhosttyTabs.xcodeproj/project.pbxproj, ghostty (submodule)
Updated ghostty submodule repository URL and commit; added six new tmux Swift sources and one tmux test file to build targets.
C API Updates
ghostty.h
Added surface manual I/O mode enums and callback typedef; added tmux action types and event enum; extended action union and tags; renamed renderer health enum value; updated fullscreen action naming; moved and reintroduced surface selection functions.
Tests
cmuxTests/TmuxTests.swift, cmuxTests/BrowserConfigTests.swift, cmuxTests/WindowAndDragTests.swift
Added comprehensive tmux test suites covering key encoding, layout diffing, type codability, capabilities, and connection state; moved InternalTabDragConfigurationTests from WindowAndDragTests.swift to BrowserConfigTests.swift.

Sequence Diagram

sequenceDiagram
    participant GhosttyC as Ghostty (C)
    participant GTV as GhosttyTerminalView
    participant TGW as TmuxGateway
    participant TCtr as TmuxController
    participant TPC as TmuxPaneClient
    participant TS as TerminalSurface

    GhosttyC->>GTV: GHOSTTY_ACTION_TMUX_CONTROL (enter)
    GTV->>TGW: handleGlobalAction(action, surface)
    TGW->>TGW: Create new TmuxGateway & TmuxController
    TGW->>TCtr: Wire controller & gateway references
    TCtr->>TS: Create gateway surface
    TGW->>TGW: Store in activeGateways
    
    GhosttyC->>GTV: GHOSTTY_ACTION_TMUX_CONTROL (windows)
    GTV->>TGW: handleGlobalAction(action, surface)
    TGW->>TCtr: handleEvent(windowsChanged)
    TCtr->>TPC: Create TmuxPaneClient per pane
    TPC->>TS: Create TerminalSurface in manual IO mode
    TPC->>TS: Set ManualIOConfig with callback
    
    GhosttyC->>GTV: Terminal output for pane
    GTV->>TS: ghostty_surface_process_output (manual IO)
    TS->>TPC: Invoke io_write_cb callback
    TPC->>TCtr: sendKeys(data, toPane)
    TCtr->>TGW: sendCommand(send-keys)
    TGW->>GhosttyC: Write to gateway surface
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Hoppity-hop through control-mode streams,
With gateways and panes and split-tree schemes,
Manual I/O flows, layouts aligned,
Tmux sessions persisted—reconnect redefined! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.72% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add tmux control mode integration' clearly and concisely describes the main change: adding native tmux control mode support to the application.
Description check ✅ Passed The PR description provides a comprehensive summary including what changed, implementation phases, new files, modified files, and a test plan. However, the description is missing explicit sections for 'Why' (rationale), 'Testing' (how it was tested), and the demo video section, which are specified in the template.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

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

❤️ Share

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

# Conflicts:
#	cmuxTests/AppDelegateShortcutRoutingTests.swift
#	cmuxTests/BrowserPanelTests.swift
@greptile-apps

greptile-apps Bot commented Mar 20, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds native tmux control mode (tmux -CC) integration to cmux across 8 implementation phases: protocol bridge, virtual surfaces, layout engine, input/output routing, resize sync, session persistence, UI integration, and advanced features (pause mode, version gates, timeout detection). The architecture is well-structured — TmuxGateway cleanly bridges the Ghostty C API, TmuxController manages the full connection lifecycle, TmuxPaneClient correctly uses a retained-self pattern for C callback safety, and TmuxLayoutEngine/TmuxKeyEncoder are solid with good test coverage.

Issues found:

  • Crash bug — removeWorkspaceForWindow in TmuxController calls paneToClient.removeValue(forKey:) inside a for (paneId, clientObj) in paneToClient loop, mutating the dictionary while iterating. This traps in debug and is undefined behavior in release. Pane keys to remove should be collected first, then removed after the loop.
  • Reconnect failure for unusual session names — The session persistence reconnect command interpolates controller.sessionName directly: "tmux -CC attach -t \(controller.sessionName)". tmux session names can contain spaces or special characters; without shell quoting the reconnect command will fail for those sessions.
  • Submodule URL points to personal fork — .gitmodules now references github.com/jamesainslie/ghostty.git instead of github.com/manaflow-ai/ghostty.git. Per the documented submodule workflow in CLAUDE.md, ghostty changes should be merged to the org fork before updating the parent repo pointer. This will break CI and team builds.
  • Command-timeout mechanism is dead code — resetCommandTimeout() and cancelCommandTimeout() are fully implemented but never called; TmuxGateway.sendCommand() does not invoke them, so spec SS12 is effectively unimplemented despite the presence of the infrastructure.
  • paneToClient should be typed as [Int: TmuxPaneClient] — The stale // TmuxPaneClient in Phase 2 comment and AnyObject type are no longer accurate now that TmuxPaneClient is fully implemented; all access sites cast away the type unnecessarily.

Confidence Score: 2/5

  • Not safe to merge — contains a crash-level dictionary mutation bug and a submodule URL pointing to a personal fork that will break CI.
  • The PR introduces a definite runtime crash in removeWorkspaceForWindow (mutation of a dictionary during for...in iteration), an infrastructure break in .gitmodules (personal fork URL), and a silent reconnect failure for sessions with special-character names. These three issues need fixing before the PR is mergeable. The remaining code quality is high — good test coverage, clean architectural separation, and correct C-interop lifetime management.
  • Sources/Tmux/TmuxController.swift (crash bug + dead code), .gitmodules (wrong submodule URL), Sources/Workspace.swift (unescaped session name).

Important Files Changed

Filename Overview
Sources/Tmux/TmuxController.swift Core tmux connection manager — has a crash-level dictionary-mutation-during-iteration bug in removeWorkspaceForWindow, dead command-timeout machinery, and paneToClient typed as [Int: AnyObject] with stale phase comment.
Sources/Tmux/TmuxGateway.swift C API bridge and write-gating layer; clean design with buffered write queue, proper weak references, and global dispatch table. No issues found.
Sources/Tmux/TmuxPaneClient.swift Virtual surface per tmux pane using Manual I/O; correctly uses retained-self pattern for C callback lifetime, with teardown() releasing the retain. No critical issues.
Sources/Workspace.swift Adds tmuxControllerId and isBuriedGateway properties, and builds session-persistence reconnect metadata — but the connection command interpolates the session name without shell-quoting, which breaks reconnect for sessions with spaces or special characters.
Sources/TabManager.swift Adds tmux workspace creation, detach dialog, session-snapshot filtering (deduplication per controller), and gateway reconnect on restore. Logic is sound.
.gitmodules Changes the ghostty submodule URL from the organisation fork (manaflow-ai/ghostty) to the PR author's personal fork (jamesainslie/ghostty), violating the documented submodule workflow and breaking CI/team builds.
ghostty.h Adds GHOSTTY_SURFACE_IO_MANUAL mode, ghostty_io_write_cb, tmux action/event C types, and moves fork-specific selection API under a clearly labelled section. Renames a few upstream enums to match updated ghostty fork.
cmuxTests/TmuxTests.swift 40 unit tests covering key encoder, layout engine, Codable types, capability gates, and session snapshot serialisation — comprehensive and correctly structured.

Sequence Diagram

sequenceDiagram
    participant GTV as GhosttyTerminalView
    participant GW as TmuxGateway
    participant TC as TmuxController
    participant TPC as TmuxPaneClient
    participant TM as TabManager

    Note over GTV,TM: Connection setup (ENTER event)
    GTV->>GW: handleGlobalAction(TMUX_ENTER, surface)
    GW->>TC: new TmuxController(gatewayPanelId)
    GW->>TC: handleEvent(.enter)
    TC->>TM: buryGatewayWorkspace()

    Note over GTV,TM: Initial sync (WINDOWS_CHANGED)
    GTV->>GW: handleGlobalAction(WINDOWS_CHANGED, payload)
    GW->>TC: handleEvent(.windowsChanged)
    TC->>TM: addWorkspace() per tmux window
    TC->>TPC: new TmuxPaneClient(paneId) per pane
    TC->>GW: enableWrite() → flush write queue
    TC->>GW: sendCommand("set-option @cmux_id …")

    Note over GTV,TM: Steady-state I/O
    GTV->>GW: handleGlobalAction(PANE_OUTPUT, data)
    GW->>TC: handleEvent(.paneOutput)
    TC->>TPC: feedOutput(data)
    TPC-->>GTV: ghostty_surface_process_output()

    TPC-->>GW: ioWriteCallback (keystroke)
    GW->>TC: sendKeys(data, paneId)
    TC->>GW: sendCommand("send-keys …")
    GW-->>GTV: ghostty_surface_text() → PTY

    Note over GTV,TM: Disconnection (EXIT event)
    GTV->>GW: handleGlobalAction(TMUX_EXIT, surface)
    GW->>TC: handleEvent(.exit)
    TC->>TPC: teardown() all clients
    TC->>TM: unburyGatewayWorkspace()
    GW->>GW: activeGateways.removeValue(panelId)
Loading

Comments Outside Diff (3)

  1. Sources/Tmux/TmuxController.swift, line 662-668 (link)

    P0 Dictionary mutated during iteration — likely crash

    paneToClient.removeValue(forKey:) is called inside a for (paneId, clientObj) in paneToClient loop, which modifies the dictionary while iterating over it. In Swift this is a runtime trap in debug builds and undefined behavior in release. The fix is to collect the keys first, then remove them after the loop.

  2. Sources/Workspace.swift, line 1688-1689 (link)

    P1 Session name not shell-escaped in reconnect command

    controller.sessionName is interpolated directly into the shell command without quoting. tmux session names can legally contain spaces and special characters (e.g., "my project" or "work:ssh"), which would produce a malformed command like tmux -CC attach -t my project that fails to reconnect on restore.

  3. Sources/Tmux/TmuxController.swift, line 759-787 (link)

    P2 Command timeout mechanism wired up but never triggered

    resetCommandTimeout() and cancelCommandTimeout() are fully implemented (with a 5-second timer and user-visible #if DEBUG print), but neither is called anywhere. TmuxGateway.sendCommand() doesn't call resetCommandTimeout, and no response path calls cancelCommandTimeout, so the timeout machinery described in spec SS12 is completely inert. Either wire these up in sendCommand / the response handler, or remove them to avoid misleading dead code.

Last reviewed commit: "Merge remote-trackin..."

Comment thread .gitmodules
[submodule "ghostty"]
path = ghostty
url = https://github.com/manaflow-ai/ghostty.git
url = https://github.com/jamesainslie/ghostty.git

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 Submodule URL points to personal fork

The ghostty submodule URL has been changed from https://github.com/manaflow-ai/ghostty.git (the organisation fork) to https://github.com/jamesainslie/ghostty.git (the PR author's personal fork). Other contributors and CI will now clone from the personal fork. Per the Ghostty submodule workflow in CLAUDE.md, changes should be committed and pushed to manaflow-ai/ghostty before updating the parent repo's submodule pointer. This should be reverted to the org fork URL (or the fork changes should be merged there first).

Suggested change
url = https://github.com/jamesainslie/ghostty.git
url = https://github.com/manaflow-ai/ghostty.git

DispatchQueue.main.asyncAfter(
deadline: .now() + Self.commandTimeoutInterval,
execute: work
)

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 paneToClient typed as [Int: AnyObject] with stale comment

The comment // TmuxPaneClient in Phase 2 is now out of date — TmuxPaneClient is fully implemented and used. Using AnyObject here requires casts at every access site and loses type safety. Since the map is only ever populated with TmuxPaneClient instances, it should be typed directly:

Suggested change
)
private(set) var paneToClient: [Int: TmuxPaneClient] = [:]

All call sites that cast clientObj as? TmuxPaneClient can then be simplified to direct access.

@cubic-dev-ai cubic-dev-ai 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.

4 issues found across 24 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/ContentView.swift">

<violation number="1" location="Sources/ContentView.swift:8122">
P2: `index` is now derived from `visibleTabs`, but `TabItemView` still uses it as an index into `tabManager.tabs` (move/reorder and shift-selection). If any hidden `isBuriedGateway` tabs exist, indices no longer align and move/selection/reorder will target the wrong workspaces.</violation>
</file>

<file name="Sources/TabManager.swift">

<violation number="1" location="Sources/TabManager.swift:5004">
P2: Tmux restore rebuilds tabs with tmux gateways appended at the end, but selection is restored by the old snapshot index, causing wrong tab selection/order after relaunch.</violation>
</file>

<file name="Sources/Tmux/TmuxController.swift">

<violation number="1" location="Sources/Tmux/TmuxController.swift:193">
P2: Mutating `windowToWorkspace` while iterating it can trap at runtime; `removeWorkspaceForWindow` removes from the dictionary inside the loop.</violation>

<violation number="2" location="Sources/Tmux/TmuxController.swift:296">
P1: Dictionary `paneToClient` is mutated during its own iteration, which can cause a runtime trap during window cleanup.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

guard let workspaceId = windowToWorkspace[windowId] else { return }

// Tear down pane clients for this window
for (paneId, clientObj) in paneToClient {

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Dictionary paneToClient is mutated during its own iteration, which can cause a runtime trap during window cleanup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Tmux/TmuxController.swift, line 296:

<comment>Dictionary `paneToClient` is mutated during its own iteration, which can cause a runtime trap during window cleanup.</comment>

<file context>
@@ -0,0 +1,482 @@
+        guard let workspaceId = windowToWorkspace[windowId] else { return }
+
+        // Tear down pane clients for this window
+        for (paneId, clientObj) in paneToClient {
+            if let client = clientObj as? TmuxPaneClient, client.tmuxWindowId == windowId {
+                client.teardown()
</file context>
Fix with Cubic

Comment thread Sources/ContentView.swift

LazyVStack(spacing: tabRowSpacing) {
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
ForEach(Array(visibleTabs.enumerated()), id: \.element.id) { index, tab in

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: index is now derived from visibleTabs, but TabItemView still uses it as an index into tabManager.tabs (move/reorder and shift-selection). If any hidden isBuriedGateway tabs exist, indices no longer align and move/selection/reorder will target the wrong workspaces.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ContentView.swift, line 8122:

<comment>`index` is now derived from `visibleTabs`, but `TabItemView` still uses it as an index into `tabManager.tabs` (move/reorder and shift-selection). If any hidden `isBuriedGateway` tabs exist, indices no longer align and move/selection/reorder will target the wrong workspaces.</comment>

<file context>
@@ -8118,7 +8119,7 @@ struct VerticalTabsSidebar: View {
 
                         LazyVStack(spacing: tabRowSpacing) {
-                            ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
+                            ForEach(Array(visibleTabs.enumerated()), id: \.element.id) { index, tab in
                                 let selectedContextIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id]
                                 let contextTargetIds = tabManager.tabs.compactMap { workspace in
</file context>
Fix with Cubic

Comment thread Sources/TabManager.swift
)
workspace.owningTabManager = self
wireClosedBrowserTracking(for: workspace)
newTabs.append(workspace)

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Tmux restore rebuilds tabs with tmux gateways appended at the end, but selection is restored by the old snapshot index, causing wrong tab selection/order after relaunch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TabManager.swift, line 5004:

<comment>Tmux restore rebuilds tabs with tmux gateways appended at the end, but selection is restored by the old snapshot index, causing wrong tab selection/order after relaunch.</comment>

<file context>
@@ -4945,6 +4987,23 @@ extension TabManager {
+            )
+            workspace.owningTabManager = self
+            wireClosedBrowserTracking(for: workspace)
+            newTabs.append(workspace)
+        }
+
</file context>
Fix with Cubic

Comment on lines +193 to +195
for (windowId, _) in windowToWorkspace where !activeWindowIds.contains(windowId) {
removeWorkspaceForWindow(windowId)
}

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Mutating windowToWorkspace while iterating it can trap at runtime; removeWorkspaceForWindow removes from the dictionary inside the loop.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Tmux/TmuxController.swift, line 193:

<comment>Mutating `windowToWorkspace` while iterating it can trap at runtime; `removeWorkspaceForWindow` removes from the dictionary inside the loop.</comment>

<file context>
@@ -0,0 +1,482 @@
+
+        // Remove workspaces for windows that no longer exist
+        let activeWindowIds = Set(windows.map(\.id))
+        for (windowId, _) in windowToWorkspace where !activeWindowIds.contains(windowId) {
+            removeWorkspaceForWindow(windowId)
+        }
</file context>
Suggested change
for (windowId, _) in windowToWorkspace where !activeWindowIds.contains(windowId) {
removeWorkspaceForWindow(windowId)
}
let obsoleteWindowIds = windowToWorkspace.keys.filter { !activeWindowIds.contains($0) }
for windowId in obsoleteWindowIds {
removeWorkspaceForWindow(windowId)
}
Fix with Cubic

@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: 15

Caution

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

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

8208-8238: ⚠️ Potential issue | 🟠 Major

Don't reuse the filtered sidebar index as the raw workspace index.

Line 8221 now enumerates visibleTabs, but TabItemView still uses index against tabManager.tabs for shift-range selection, move up/down, and reorder targets. lastSidebarSelectionIndex is also still seeded from raw tabManager.tabs.firstIndex(...) elsewhere in this file, so once a buried gateway exists those actions will target the wrong workspace. The shortcut badge can drift for the same reason: digit routing in Sources/AppDelegate.swift:9481-9488 and Sources/cmuxApp.swift:814-819 still maps over manager.tabs.count, and Sources/TabManager.swift:3089-3095 selects the unfiltered index. Pass a raw index separately (or resolve by tab.id) so the sidebar and shortcut routing share one ordering.

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

In `@Sources/ContentView.swift` around lines 8208 - 8238, The visibleTabs
enumeration creates a filtered index that must not be used as the raw workspace
index; compute the unfiltered/raw index for each tab (e.g. find rawIndex =
tabManager.tabs.firstIndex(where: { $0.id == tab.id })) and pass that rawIndex
into TabItemView (instead of the filtered `index`) and into
workspaceShortcutDigit/WorkspaceShortcutMapper.commandDigitForWorkspace so
shift-range selection, move up/down, reorder targets and
lastSidebarSelectionIndex all use the same unfiltered ordering; alternatively
resolve actions by `tab.id` everywhere (ensure TabItemView,
lastSidebarSelectionIndex seeding, and shortcut routing consistently use the raw
index or id).
🧹 Nitpick comments (4)
Sources/Tmux/TmuxLayoutEngine.swift (1)

67-78: Consider adding a debug assertion for the empty children case.

The guard correctly handles an invalid state, but silently returning a dummy leaf (paneId: -1) could mask bugs. A debug assertion would help catch unexpected states during development.

💡 Optional: add debug assertion
     private static func foldChildren(
         _ children: [TmuxLayoutNode],
         orientation: BinaryNode.SplitOrientation
     ) -> BinaryNode {
         guard !children.isEmpty else {
             // Should never happen in valid tmux layouts
+            `#if` DEBUG
+            assertionFailure("TmuxLayoutEngine: foldChildren called with empty children")
+            `#endif`
             return .leaf(paneId: -1, width: 0, height: 0, x: 0, y: 0)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Tmux/TmuxLayoutEngine.swift` around lines 67 - 78, In
foldChildren(_:orientation:) add a debug assertion before returning the sentinel
dummy leaf to catch invalid states in development: replace the silent
guard-return path for empty children with an assertionFailure (or assert) that
includes a clear message like "foldChildren called with empty children" and any
relevant context (orientation), then still return the existing .leaf(paneId: -1,
width: 0, height: 0, x: 0, y: 0) to preserve behavior in release builds;
reference the function foldChildren and the dummy leaf return when locating
where to add the assertion.
cmuxTests/TmuxTests.swift (1)

529-533: Use a real suffixed tmux version in this capability fixture.

tmux commonly reports versions like 3.5a, but versionCompare drops non-numeric suffixes, so testVersion35aSupportsAllFeatures currently only proves 3.5. Either normalize suffixes in the helper or add a fixture that passes an actual suffixed version string.

Also applies to: 653-665

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

In `@cmuxTests/TmuxTests.swift` around lines 529 - 533, The test fixture uses
versionCompare("3.5", ...) but should simulate a real suffixed tmux version;
update the TmuxCapabilities versionCheck closure in
testVersion35aSupportsAllFeatures to call versionCompare("3.5a", atLeast:
minimum) (or adjust the versionCompare helper to normalize/strip suffixes before
comparison) so the test actually exercises a suffixed version; apply the same
change to the other fixtures referenced around the test (the cases covering
lines 653-665) that intend to use suffixed versions.
Sources/TabManager.swift (1)

2213-2231: Extract the tmux detach/close decision into a shared helper.

This branch is the only close flow that bypasses confirmCloseHandler, and the bulk-close path still jumps straight to closeWorkspaceIfRunningProcess(...) instead of reusing the tmux logic. Centralizing the decision would keep single-close, multi-close, and tests consistent.

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

In `@Sources/TabManager.swift` around lines 2213 - 2231, The tmux detach/close
prompt logic duplicated here (checking workspace.tmuxControllerId,
TmuxController.controller(forId:), showing NSAlert, and calling
controller.detach()) should be moved into a shared helper (e.g., a new function
like confirmDetachFromTmux(for workspace: Workspace) -> Bool or
handleTmuxCloseDecision(for:workspace:, completion:)), and existing callers
(single-close path here, the bulk-close path that currently calls
closeWorkspaceIfRunningProcess(...), and unit tests) should call that helper
instead of duplicating the logic; ensure the helper returns a decision or
invokes a completion so callers can proceed to
closeWorkspaceIfRunningProcess(...) only when appropriate, and update tests to
exercise the new helper rather than bypassing confirmCloseHandler.
Sources/Tmux/TmuxController.swift (1)

418-420: Use dlog instead of print for the DEBUG timeout trace.

This is DEBUG-only instrumentation, so it should go through the shared debug event log instead of stdout.

🪵 Suggested fix
 `#if` DEBUG
-        print("[tmux] command timeout after \(Self.commandTimeoutInterval)s — tmux may be unresponsive")
+        dlog("[tmux] command timeout after \(Self.commandTimeoutInterval)s — tmux may be unresponsive")
 `#endif`
As per coding guidelines, "All debug events (keys, mouse, focus, splits, tabs) in DEBUG builds go to the debug event log. Use free function `dlog("message")` to log events. Wrap all call sites in `#if DEBUG` / `#endif`. The entire implementation is wrapped in `#if DEBUG`."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Tmux/TmuxController.swift` around lines 418 - 420, Replace the
debug-only stdout print in the TmuxController timeout handler with the shared
debug event logger: inside the existing `#if` DEBUG / `#endif` block, swap
print("[tmux] command timeout after \(Self.commandTimeoutInterval)s — tmux may
be unresponsive") for dlog("[tmux] command timeout after
\(Self.commandTimeoutInterval)s — tmux may be unresponsive"); keep the same
interpolated message and DEBUG guard so the event goes to the debug event log
(reference: TmuxController and Self.commandTimeoutInterval).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.gitmodules:
- Line 3: Update the ghostty submodule URL to point to the canonical
organization fork instead of the personal fork: locate the "ghostty" submodule
entry in .gitmodules and replace the url value
"https://github.com/jamesainslie/ghostty.git" with the organization fork URL
(e.g., the manaflow-ai/ghostty repository), then run git submodule sync && git
submodule update --init --recursive to ensure the change is applied and
committed; ensure subsequent ghostty changes are made and pushed inside the
ghostty submodule itself.

In `@ghostty`:
- Line 1: The submodule update for ghostty is on a detached HEAD and must be
pushed to the manaflow-ai/ghostty remote main branch and documented; check out
the ghostty submodule commit locally, create/force-update a branch or push the
detached commit to the remote main (git push manaflow-ai HEAD:refs/heads/main)
so the submodule commit exists on manaflow-ai/ghostty main, then update
docs/ghostty-fork.md to describe the tmux control mode API additions and any
other fork-specific changes introduced by this update (mention relevant API
names and behaviors) before updating the parent repo pointer and merging.

In `@Sources/cmuxApp.swift`:
- Around line 447-448: The CommandMenu usage calls String(localized:
"menu.tmux.title", ...) and "menu.tmux.detach" which are missing from the
xcstrings catalog; open Resources/Localizable.xcstrings and add entries for
"menu.tmux.title" and "menu.tmux.detach" with English and Japanese translations
(provide the English defaults "tmux" and "Detach" and equivalent Japanese
strings), save the file and ensure the keys match exactly so CommandMenu and
Button find the localized values at runtime.
- Line 452: Add the missing localization entries "menu.tmux.title" and
"menu.tmux.detach" to Resources/Localizable.xcstrings with English and Japanese
translations, and make the menu's disabled state reactive by subscribing to the
tmux connection publisher: replace the static use of
TmuxController.hasActiveConnection with a reactive observer (for example use
.onReceive(TmuxController.shared.$activeControllers) or .onChange(of:
TmuxController.shared.activeControllers) on the Button or surrounding Menu) so
the disabled binding updates when TmuxController.activeControllers (or the
published connection state) changes.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 2556-2568: The surface config may inherit manual I/O fields from
configTemplate when manualIOConfig is nil; in createSurface(for:) reset the I/O
fields on the copied surfaceConfig to the PTY defaults (clear io_mode, set
io_write_cb and io_write_userdata to their non-manual/NULL defaults) before
applying any manualIOConfig override so manualIOConfig/isManualIOMode remains
the single source of truth; update the same reset logic for the second
occurrence referenced around the lines noted (similar to 3152-3157).

In `@Sources/TabManager.swift`:
- Around line 4910-4924: The code collapses multiple tmux snapshots by tracking
seenTmuxControllers and building restorableTabs, but loses original positions so
selectedWorkspaceIndex can point to the wrong tab; update the collapse logic to
record the original snapshot index of the first seen tmux controller (e.g. store
a mapping from controller UUID to its first index) and when building
restorableTabs insert that first tmux gateway at its original snapshot position
instead of just appending later; then adjust any selection mapping logic
(selectedWorkspaceIndex) to map to the preserved in-place entry for that
controller (use the controller->firstIndex map) — apply the same fix in the
analogous block around the code handling restoration at the later range (the
block referenced at 4964-5005).

In `@Sources/Tmux/TmuxController.swift`:
- Around line 198-213: The method handleLayoutChange decodes the layout but then
discards it ("_ = layout"), so runtime split/resize/add events never update the
workspace tree or pane mappings; replace that no-op with logic to reconcile the
decoded TmuxLayoutNode with the workspace identified by workspaceId: traverse
the layout tree, update the workspace/tab structure in tabManager (e.g., call or
implement a function like applyLayoutToWorkspace(workspaceId: Int, layout:
TmuxLayoutNode) or tabManager?.applyLayout(workspaceId:workspaceId,
layout:layout)), and update paneToClient by adding mappings for newly discovered
pane IDs and removing mappings for panes that no longer exist; ensure any
created/removed panes update client state and output routing so cmux stays in
sync with tmux.
- Around line 270-273: The code is auto-selecting the first workspace during
passive sync by passing select: windowToWorkspace.isEmpty to
tabManager.addWorkspace; change this so passive/restore paths never mutate
selection—only explicit focus-intent code should. Update the call in
TmuxController to pass select: false (or a newly-propagated explicitFocusIntent
boolean) instead of windowToWorkspace.isEmpty, and if needed thread an explicit
focus flag from the caller into the place that calls tabManager.addWorkspace so
only intentional “open/focus” actions can pass true. Ensure references:
TmuxController, tabManager.addWorkspace, and windowToWorkspace are updated
accordingly.
- Around line 171-179: Read the existing tmux session option `@cmux_id` before you
overwrite it: call the routine that queries the current session option (instead
of immediately invoking gateway.sendCommand("set-option -s `@cmux_id`
\(id.uuidString)\n")), capture that value and pass it into checkDoubleAttach()
(or let checkDoubleAttach() perform the read) so checkDoubleAttach() observes
the previous owner; only after probing and handling double-attach should you
persist the new controller ID with gateway.sendCommand, while keeping
enablePauseModeIfSupported() behavior unchanged.
- Around line 436-457: In teardown(), before clearing pane/window maps, iterate
the workspaces referenced in windowToWorkspace and tell the tabManager to remove
each workspace so the sidebar doesn't retain dead tabs; specifically, after
unburyGatewayWorkspace() and before clearing
paneToClient/paneToPanelId/windowToWorkspace, call
tabManager.removeWorkspace(...) (or the appropriate method on tabManager) for
each workspace in windowToWorkspace, then proceed with tearing down pane clients
and removing the maps.
- Around line 392-412: The command-timeout logic is never used; call
resetCommandTimeout() before sending any tmux command and call
cancelCommandTimeout() when tmux responses/events arrive so
handleCommandTimeout() can fire on timeout. Concretely, introduce (or use) a
central sendCommand(_:) wrapper that invokes resetCommandTimeout() then
delegates to gateway.sendCommand(...), replace existing direct
gateway.sendCommand(...) call sites to route through sendCommand(_:), and add
cancelCommandTimeout() to the tmux response/event ingress path (the method
handling inbound tmux data) so the timer is cancelled whenever a response is
received; look for resetCommandTimeout(), cancelCommandTimeout(),
handleCommandTimeout(), sendCommand(_:), and gateway.sendCommand to locate where
to wire these calls.

In `@Sources/Tmux/TmuxGateway.swift`:
- Around line 109-119: The controller is being wired to
AppDelegate.shared?.tabManager (the active-window manager) which may be wrong
for this surface; instead resolve the owning TabManager from the surface/panel
identity first. In the block that creates TmuxGateway and TmuxController
(symbols: TmuxGateway, TmuxController, gateway.controller,
controller.gatewaySurface, controller.tabManager, activeGateways, panelId,
surface), call AppDelegate.shared?.locateSurface(surfaceId:) (or equivalent
surface lookup) to get the correct TabManager for this surface and assign that
to controller.tabManager, and only fall back to AppDelegate.shared?.tabManager
if locateSurface returns nil. Ensure you still set gateway.controller,
controller.gatewaySurface, and insert activeGateways[panelId] = gateway.

In `@Sources/Tmux/TmuxKeyEncoder.swift`:
- Around line 29-33: In TmuxKeyEncoder.literalCharacterSet remove the insertion
of space (0x20) from the literalCharacterSet so spaces are not treated as
literal characters; instead ensure spaces are encoded as hex (0x20) by the
encoder or handled by quoting the literal argument when building the send-keys
command; also remove any other set.insert(0x20) occurrences in the same
file/class so the send-keys -lt path emits "hello world" as a single
quoted/hex-encoded argument rather than separate arguments.

In `@Sources/Tmux/TmuxPaneClient.swift`:
- Around line 38-44: In teardown(), ensure the C callback cannot dereference a
freed object by clearing or invalidating the Manual I/O callback before
releasing the retained self: explicitly set surface.manualIOConfig = nil (or
otherwise replace writeCallback/userdata with safe no-op values) prior to
calling retainedSelf?.release() so that ioWriteCallback will not be invoked with
userdata pointing at a released Unmanaged instance; alternatively, postpone
releasing the Unmanaged.passRetained(self) (retainedSelf) until after the
surface is fully torn down and freed. Reference: ioWriteCallback, retainedSelf,
teardown(), surface.manualIOConfig, ManualIOConfig,
Unmanaged.passRetained(self).

In `@Sources/Workspace.swift`:
- Around line 154-155: The current logic sets effectiveIncludeScrollback =
tmuxControllerId == nil && includeScrollback which disables scrollback whenever
a tmuxControllerId exists even if tmuxInfo lookup failed; change the condition
to include scrollback when includeScrollback is true and either there's no tmux
controller or the controller lookup produced no tmuxInfo (i.e.
effectiveIncludeScrollback = includeScrollback && (tmuxControllerId == nil ||
tmuxInfo == nil)). Update the same conditional logic in the other occurrences
referenced (around the 201–212 block) so snapshots retain scrollback when
tmuxInfo is nil and ensure reconnect metadata handling is still preserved.

---

Outside diff comments:
In `@Sources/ContentView.swift`:
- Around line 8208-8238: The visibleTabs enumeration creates a filtered index
that must not be used as the raw workspace index; compute the unfiltered/raw
index for each tab (e.g. find rawIndex = tabManager.tabs.firstIndex(where: {
$0.id == tab.id })) and pass that rawIndex into TabItemView (instead of the
filtered `index`) and into
workspaceShortcutDigit/WorkspaceShortcutMapper.commandDigitForWorkspace so
shift-range selection, move up/down, reorder targets and
lastSidebarSelectionIndex all use the same unfiltered ordering; alternatively
resolve actions by `tab.id` everywhere (ensure TabItemView,
lastSidebarSelectionIndex seeding, and shortcut routing consistently use the raw
index or id).

---

Nitpick comments:
In `@cmuxTests/TmuxTests.swift`:
- Around line 529-533: The test fixture uses versionCompare("3.5", ...) but
should simulate a real suffixed tmux version; update the TmuxCapabilities
versionCheck closure in testVersion35aSupportsAllFeatures to call
versionCompare("3.5a", atLeast: minimum) (or adjust the versionCompare helper to
normalize/strip suffixes before comparison) so the test actually exercises a
suffixed version; apply the same change to the other fixtures referenced around
the test (the cases covering lines 653-665) that intend to use suffixed
versions.

In `@Sources/TabManager.swift`:
- Around line 2213-2231: The tmux detach/close prompt logic duplicated here
(checking workspace.tmuxControllerId, TmuxController.controller(forId:), showing
NSAlert, and calling controller.detach()) should be moved into a shared helper
(e.g., a new function like confirmDetachFromTmux(for workspace: Workspace) ->
Bool or handleTmuxCloseDecision(for:workspace:, completion:)), and existing
callers (single-close path here, the bulk-close path that currently calls
closeWorkspaceIfRunningProcess(...), and unit tests) should call that helper
instead of duplicating the logic; ensure the helper returns a decision or
invokes a completion so callers can proceed to
closeWorkspaceIfRunningProcess(...) only when appropriate, and update tests to
exercise the new helper rather than bypassing confirmCloseHandler.

In `@Sources/Tmux/TmuxController.swift`:
- Around line 418-420: Replace the debug-only stdout print in the TmuxController
timeout handler with the shared debug event logger: inside the existing `#if`
DEBUG / `#endif` block, swap print("[tmux] command timeout after
\(Self.commandTimeoutInterval)s — tmux may be unresponsive") for dlog("[tmux]
command timeout after \(Self.commandTimeoutInterval)s — tmux may be
unresponsive"); keep the same interpolated message and DEBUG guard so the event
goes to the debug event log (reference: TmuxController and
Self.commandTimeoutInterval).

In `@Sources/Tmux/TmuxLayoutEngine.swift`:
- Around line 67-78: In foldChildren(_:orientation:) add a debug assertion
before returning the sentinel dummy leaf to catch invalid states in development:
replace the silent guard-return path for empty children with an assertionFailure
(or assert) that includes a clear message like "foldChildren called with empty
children" and any relevant context (orientation), then still return the existing
.leaf(paneId: -1, width: 0, height: 0, x: 0, y: 0) to preserve behavior in
release builds; reference the function foldChildren and the dummy leaf return
when locating where to add the assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ff60dec-a14e-45c0-9949-d10a55f65867

📥 Commits

Reviewing files that changed from the base of the PR and between cc0fc55 and 5e711d8.

📒 Files selected for processing (20)
  • .gitmodules
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Tmux/TmuxController.swift
  • Sources/Tmux/TmuxGateway.swift
  • Sources/Tmux/TmuxKeyEncoder.swift
  • Sources/Tmux/TmuxLayoutEngine.swift
  • Sources/Tmux/TmuxPaneClient.swift
  • Sources/Tmux/TmuxTypes.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • cmuxTests/BrowserConfigTests.swift
  • cmuxTests/TmuxTests.swift
  • cmuxTests/WindowAndDragTests.swift
  • ghostty
  • ghostty.h
💤 Files with no reviewable changes (1)
  • cmuxTests/WindowAndDragTests.swift

Comment thread .gitmodules
[submodule "ghostty"]
path = ghostty
url = https://github.com/manaflow-ai/ghostty.git
url = https://github.com/jamesainslie/ghostty.git

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 canonical org fork for ghostty submodule URL.

Line 3 switches ghostty to a personal fork, which can break reproducible checkouts/CI and team submodule workflow expectations. Please keep the submodule URL on the organization fork.

🔧 Proposed fix
-	url = https://github.com/jamesainslie/ghostty.git
+	url = https://github.com/manaflow-ai/ghostty.git

Based on learnings: “Ghostty changes must be committed in the ghostty submodule and pushed to the manaflow-ai/ghostty fork.”

📝 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
url = https://github.com/jamesainslie/ghostty.git
url = https://github.com/manaflow-ai/ghostty.git
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitmodules at line 3, Update the ghostty submodule URL to point to the
canonical organization fork instead of the personal fork: locate the "ghostty"
submodule entry in .gitmodules and replace the url value
"https://github.com/jamesainslie/ghostty.git" with the organization fork URL
(e.g., the manaflow-ai/ghostty repository), then run git submodule sync && git
submodule update --init --recursive to ensure the change is applied and
committed; ensure subsequent ghostty changes are made and pushed inside the
ghostty submodule itself.

Comment thread ghostty
@@ -1 +1 @@
Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42
Subproject commit d0ac04eac99a41a06a7f87f12566af2f3f093b35

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the ghostty submodule commit is pushed to remote main

cd ghostty || exit 1

# Check if the current commit is an ancestor of origin/main
if git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then
  echo "✓ Submodule commit is on remote main"
  exit 0
else
  echo "✗ Submodule commit is NOT on remote main (detached HEAD or unpushed branch)"
  exit 1
fi

Repository: manaflow-ai/cmux

Length of output: 135


🏁 Script executed:

#!/bin/bash
# Description: Check if docs/ghostty-fork.md mentions tmux or recent updates

if [ -f docs/ghostty-fork.md ]; then
  echo "=== Checking docs/ghostty-fork.md for tmux-related updates ==="
  rg -i "tmux|control.mode|d0ac04ea" docs/ghostty-fork.md || echo "No tmux-related content found in fork docs"
else
  echo "⚠️  docs/ghostty-fork.md does not exist"
fi

Repository: manaflow-ai/cmux

Length of output: 165


Push the ghostty commit to remote main and update docs/ghostty-fork.md before merging.

The submodule commit is currently on a detached HEAD and has not been pushed to the manaflow-ai/ghostty remote main branch. Per submodule best practices, this must be pushed before updating the parent repo pointer.

Additionally, docs/ghostty-fork.md has not been updated to document the tmux control mode API additions and other fork-specific changes introduced in this update.

Please:

  1. Push the ghostty commit to the manaflow-ai/ghostty repository's main branch
  2. Update docs/ghostty-fork.md to document the tmux control mode changes and any other fork modifications
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghostty` at line 1, The submodule update for ghostty is on a detached HEAD
and must be pushed to the manaflow-ai/ghostty remote main branch and documented;
check out the ghostty submodule commit locally, create/force-update a branch or
push the detached commit to the remote main (git push manaflow-ai
HEAD:refs/heads/main) so the submodule commit exists on manaflow-ai/ghostty
main, then update docs/ghostty-fork.md to describe the tmux control mode API
additions and any other fork-specific changes introduced by this update (mention
relevant API names and behaviors) before updating the parent repo pointer and
merging.

Comment thread Sources/cmuxApp.swift
Comment on lines +447 to +448
CommandMenu(String(localized: "menu.tmux.title", defaultValue: "tmux")) {
Button(String(localized: "menu.tmux.detach", defaultValue: "Detach")) {

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

XCSTR=$(fd -t f 'Localizable\.xcstrings$' Resources | head -n 1)
test -n "${XCSTR}"

jq '.strings["menu.tmux.title"], .strings["menu.tmux.detach"]' "$XCSTR"

Repository: manaflow-ai/cmux

Length of output: 69


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the xcstrings file
XCSTR=$(fd -t f 'Localizable\.xcstrings$' Resources | head -n 1)
if [ -z "$XCSTR" ]; then
  echo "ERROR: Localizable.xcstrings not found"
  exit 1
fi

echo "=== File found: $XCSTR ==="
echo "=== File size ==="
wc -l "$XCSTR"

echo "=== Root keys in JSON ==="
jq 'keys' "$XCSTR" | head -20

echo "=== Checking for any tmux-related keys ==="
jq '.strings | keys[] | select(contains("tmux"))' "$XCSTR" 2>/dev/null || echo "No tmux keys found"

echo "=== Checking .strings key count ==="
jq '.strings | length' "$XCSTR"

echo "=== Verifying our target keys ==="
jq '.strings | has("menu.tmux.title"), has("menu.tmux.detach")' "$XCSTR"

Repository: manaflow-ai/cmux

Length of output: 371


Add menu.tmux.title and menu.tmux.detach keys to Resources/Localizable.xcstrings.

The code at lines 447–448 references localization keys "menu.tmux.title" and "menu.tmux.detach" that are not present in the xcstrings catalog. Both keys must be added with English and Japanese translations to satisfy the localization requirement.

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

In `@Sources/cmuxApp.swift` around lines 447 - 448, The CommandMenu usage calls
String(localized: "menu.tmux.title", ...) and "menu.tmux.detach" which are
missing from the xcstrings catalog; open Resources/Localizable.xcstrings and add
entries for "menu.tmux.title" and "menu.tmux.detach" with English and Japanese
translations (provide the English defaults "tmux" and "Detach" and equivalent
Japanese strings), save the file and ensure the keys match exactly so
CommandMenu and Button find the localized values at runtime.

Comment thread Sources/cmuxApp.swift
TmuxController.detachAll()
}
.keyboardShortcut("d", modifiers: [.control, .command])
.disabled(!TmuxController.hasActiveConnection)

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

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify how hasActiveConnection is defined and whether changes are observable.
fd -t f 'TmuxController.swift$' Sources
rg -n -C3 '\bhasActiveConnection\b|@Published|ObservableObject|objectWillChange|NotificationCenter|detachAll\s*\(' Sources/Tmux/TmuxController.swift Sources/cmuxApp.swift

Repository: manaflow-ai/cmux

Length of output: 6708


🏁 Script executed:

fd -t f 'Localizable.xcstrings' Resources

Repository: manaflow-ai/cmux

Length of output: 91


🏁 Script executed:

rg -n 'menu\.tmux\.' Resources/

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -n -B5 -A5 'CommandMenu.*menu.tmux' Sources/cmuxApp.swift

Repository: manaflow-ai/cmux

Length of output: 613


🏁 Script executed:

cat Resources/Localizable.xcstrings | head -100

Repository: manaflow-ai/cmux

Length of output: 2215


🏁 Script executed:

rg '"menu\.tmux' Resources/Localizable.xcstrings

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

wc -l Resources/Localizable.xcstrings && rg -i 'tmux' Resources/Localizable.xcstrings

Repository: manaflow-ai/cmux

Length of output: 1232


🏁 Script executed:

rg '"menu\.tmux\.(title|detach)"' Resources/Localizable.xcstrings

Repository: manaflow-ai/cmux

Length of output: 42


Add missing localization keys and fix non-reactive disabled state.

The tmux menu references localization keys "menu.tmux.title" and "menu.tmux.detach" (lines 447–448), but these keys are absent from Resources/Localizable.xcstrings. Add both keys with English and Japanese translations.

Additionally, line 452 reads TmuxController.hasActiveConnection without any subscription mechanism. The disabled state will not update reactively when the tmux connection state changes. Subscribe to connection state changes using .onReceive() on the button or wrap the menu in .onChange() to observe TmuxController.activeControllers or a similar published source.

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

In `@Sources/cmuxApp.swift` at line 452, Add the missing localization entries
"menu.tmux.title" and "menu.tmux.detach" to Resources/Localizable.xcstrings with
English and Japanese translations, and make the menu's disabled state reactive
by subscribing to the tmux connection publisher: replace the static use of
TmuxController.hasActiveConnection with a reactive observer (for example use
.onReceive(TmuxController.shared.$activeControllers) or .onChange(of:
TmuxController.shared.activeControllers) on the Button or surrounding Menu) so
the disabled binding updates when TmuxController.activeControllers (or the
published connection state) changes.

Comment on lines +2556 to +2568
/// Configuration for Manual I/O mode (used by tmux virtual surfaces).
/// When set, the surface is created without a PTY; keystrokes are routed
/// through the write callback instead of a child process.
struct ManualIOConfig {
let writeCallback: ghostty_io_write_cb
let userdata: UnsafeMutableRawPointer
}

/// Set before surface creation to enable Manual I/O mode.
var manualIOConfig: ManualIOConfig?

/// Whether this surface is in Manual I/O mode (tmux virtual pane).
var isManualIOMode: Bool { manualIOConfig != 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

Clear inherited I/O fields when manualIOConfig is unset.

These lines make manualIOConfig the switch for manual mode, but createSurface(for:) only overwrites the io_* fields inside the if let manualIO block. Because surfaceConfig is copied from configTemplate earlier in the method, a template taken from a tmux pane can leave io_mode, io_write_cb, or io_write_userdata behind even when manualIOConfig == nil. That can create the new surface in the wrong I/O mode or keep a stale raw userdata pointer alive. Reset those fields to the normal PTY defaults before applying the manual override so manualIOConfig stays the single source of truth.

Also applies to: 3152-3157

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

In `@Sources/GhosttyTerminalView.swift` around lines 2556 - 2568, The surface
config may inherit manual I/O fields from configTemplate when manualIOConfig is
nil; in createSurface(for:) reset the I/O fields on the copied surfaceConfig to
the PTY defaults (clear io_mode, set io_write_cb and io_write_userdata to their
non-manual/NULL defaults) before applying any manualIOConfig override so
manualIOConfig/isManualIOMode remains the single source of truth; update the
same reset logic for the second occurrence referenced around the lines noted
(similar to 3152-3157).

Comment on lines +436 to +457
private func teardown() {
connectionState = .disconnected

resizeDebounceTimer?.cancel()
resizeDebounceTimer = nil
commandTimeoutTimer?.cancel()
commandTimeoutTimer = nil
for (_, timer) in unpauseTimers { timer.cancel() }
unpauseTimers.removeAll()
pausedPanes.removeAll()

unburyGatewayWorkspace()

// Tear down all pane clients
for (_, clientObj) in paneToClient {
(clientObj as? TmuxPaneClient)?.teardown()
}

// Clean up entity maps
paneToClient.removeAll()
paneToPanelId.removeAll()
windowToWorkspace.removeAll()

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

Remove the tmux workspaces before clearing the controller maps.

This tears down pane clients and clears windowToWorkspace, but it never removes those workspaces from tabManager. After detach/exit, the sidebar can retain dead tmux tabs with no backing controller.

🧹 Suggested fix
-        // Tear down all pane clients
-        for (_, clientObj) in paneToClient {
-            (clientObj as? TmuxPaneClient)?.teardown()
-        }
-
-        // Clean up entity maps
-        paneToClient.removeAll()
-        paneToPanelId.removeAll()
-        windowToWorkspace.removeAll()
+        for windowId in Array(windowToWorkspace.keys) {
+            removeWorkspaceForWindow(windowId)
+        }
+
+        paneToClient.removeAll()
+        paneToPanelId.removeAll()
+        windowToWorkspace.removeAll()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Tmux/TmuxController.swift` around lines 436 - 457, In teardown(),
before clearing pane/window maps, iterate the workspaces referenced in
windowToWorkspace and tell the tabManager to remove each workspace so the
sidebar doesn't retain dead tabs; specifically, after unburyGatewayWorkspace()
and before clearing paneToClient/paneToPanelId/windowToWorkspace, call
tabManager.removeWorkspace(...) (or the appropriate method on tabManager) for
each workspace in windowToWorkspace, then proceed with tearing down pane clients
and removing the maps.

Comment on lines +109 to +119
if action.event == GHOSTTY_TMUX_ENTER {
// Create a new gateway and controller for this surface
let gateway = TmuxGateway()
let controller = TmuxController(
gatewayPanelId: panelId,
gateway: gateway
)
gateway.controller = controller
controller.gatewaySurface = surface
controller.tabManager = AppDelegate.shared?.tabManager
activeGateways[panelId] = gateway

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

Resolve the owning TabManager from this surface, not from AppDelegate.shared?.tabManager.

AppDelegate.shared?.tabManager is the active-window manager, not necessarily the one that owns panelId. In multi-window tmux sessions this can wire the controller to the wrong window model. Resolve the manager from the surface/panel identity first, e.g. via locateSurface(surfaceId:).

Based on learnings: panel/surface-routed work must fall back to AppDelegate.shared?.locateSurface(surfaceId:) to find the correct TabManager across windows and avoid active-window bias.

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

In `@Sources/Tmux/TmuxGateway.swift` around lines 109 - 119, The controller is
being wired to AppDelegate.shared?.tabManager (the active-window manager) which
may be wrong for this surface; instead resolve the owning TabManager from the
surface/panel identity first. In the block that creates TmuxGateway and
TmuxController (symbols: TmuxGateway, TmuxController, gateway.controller,
controller.gatewaySurface, controller.tabManager, activeGateways, panelId,
surface), call AppDelegate.shared?.locateSurface(surfaceId:) (or equivalent
surface lookup) to get the correct TabManager for this surface and assign that
to controller.tabManager, and only fall back to AppDelegate.shared?.tabManager
if locateSurface returns nil. Ensure you still set gateway.controller,
controller.gatewaySurface, and insert activeGateways[panelId] = gateway.

Comment on lines +29 to +33
// Special literal characters: +/):,_
for ch: UInt8 in [0x2B, 0x2F, 0x29, 0x3A, 0x2C, 0x5F] { set.insert(ch) }
// Space is also literal
set.insert(0x20)
return set

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

Space in literal batch breaks command parsing.

Including space (0x20) in literalCharacterSet causes incorrect behavior. When the literal batch contains spaces, the generated command:

send-keys -lt %0 hello world

is parsed by tmux as separate arguments hello and world, not as the string "hello world".

Either remove space from the literal set (encode it as 0x20 hex), or quote the literal argument.

🐛 Suggested fix: remove space from literal set
         // Special literal characters: +/):,_
         for ch: UInt8 in [0x2B, 0x2F, 0x29, 0x3A, 0x2C, 0x5F] { set.insert(ch) }
-        // Space is also literal
-        set.insert(0x20)
         return set

Also applies to: 48-48

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

In `@Sources/Tmux/TmuxKeyEncoder.swift` around lines 29 - 33, In
TmuxKeyEncoder.literalCharacterSet remove the insertion of space (0x20) from the
literalCharacterSet so spaces are not treated as literal characters; instead
ensure spaces are encoded as hex (0x20) by the encoder or handled by quoting the
literal argument when building the send-keys command; also remove any other
set.insert(0x20) occurrences in the same file/class so the send-keys -lt path
emits "hello world" as a single quoted/hex-encoded argument rather than separate
arguments.

Comment on lines +38 to +44
// Retain self for the C callback and configure Manual I/O
let retained = Unmanaged.passRetained(self)
self.retainedSelf = retained
surface.manualIOConfig = TerminalSurface.ManualIOConfig(
writeCallback: Self.ioWriteCallback,
userdata: retained.toOpaque()
)

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Manual I/O callback installation / clearing:"
rg -nP "manualIOConfig\s*=\s*nil|manualIOConfig" Sources/GhosttyTerminalView.swift Sources/Tmux -C 2

echo
echo "Callback userdata lifetime and teardown ordering:"
rg -n "ioWriteCallback|retainedSelf|ghostty_surface_free|teardown\(" Sources/Tmux/TmuxPaneClient.swift Sources/Tmux/TmuxController.swift -C 3

Repository: manaflow-ai/cmux

Length of output: 6262


Callback userdata outlives its retained context in teardown().

The ioWriteCallback dereferences userdata via takeUnretainedValue(), but teardown() releases the retained self before freeing the surface. If the callback fires after retainedSelf?.release() but before the surface is fully torn down, it will dereference a freed object. Invalidate the callback (e.g., surface.manualIOConfig = nil) before releasing retainedSelf, or ensure the retained object stays alive until the surface is completely freed.

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

In `@Sources/Tmux/TmuxPaneClient.swift` around lines 38 - 44, In teardown(),
ensure the C callback cannot dereference a freed object by clearing or
invalidating the Manual I/O callback before releasing the retained self:
explicitly set surface.manualIOConfig = nil (or otherwise replace
writeCallback/userdata with safe no-op values) prior to calling
retainedSelf?.release() so that ioWriteCallback will not be invoked with
userdata pointing at a released Unmanaged instance; alternatively, postpone
releasing the Unmanaged.passRetained(self) (retainedSelf) until after the
surface is fully torn down and freed. Reference: ioWriteCallback, retainedSelf,
teardown(), surface.manualIOConfig, ManualIOConfig,
Unmanaged.passRetained(self).

Comment thread Sources/Workspace.swift
Comment on lines +154 to +155
// tmux workspaces skip scrollback — tmux re-sends content on reconnect
let effectiveIncludeScrollback = tmuxControllerId == nil && includeScrollback

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

Prevent snapshot data loss when tmux lookup fails.

effectiveIncludeScrollback is disabled as soon as tmuxControllerId exists, but tmuxInfo can still end up nil (missing controller / empty session name). In that case, the snapshot stores neither reconnect metadata nor scrollback.

💡 Proposed fix
 func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot {
-    // tmux workspaces skip scrollback — tmux re-sends content on reconnect
-    let effectiveIncludeScrollback = tmuxControllerId == nil && includeScrollback
+    // Build tmux session info first so scrollback fallback is preserved if tmux metadata is unavailable.
+    let tmuxInfo: TmuxSessionInfo? = tmuxControllerId.flatMap { controllerId in
+        guard let controller = TmuxController.controller(forId: controllerId),
+              !controller.sessionName.isEmpty else {
+            return nil
+        }
+        return TmuxSessionInfo(
+            sessionName: controller.sessionName,
+            connectionCommand: "tmux -CC attach -t \(controller.sessionName)"
+        )
+    }
+    // tmux workspaces skip scrollback only when reconnect metadata is actually available.
+    let effectiveIncludeScrollback = includeScrollback && tmuxInfo == nil
@@
-    // Build tmux session info if this workspace is managed by a tmux controller
-    let tmuxInfo: TmuxSessionInfo? = tmuxControllerId.flatMap { controllerId in
-        guard let controller = TmuxController.controller(forId: controllerId),
-              !controller.sessionName.isEmpty else {
-            return nil
-        }
-        return TmuxSessionInfo(
-            sessionName: controller.sessionName,
-            connectionCommand: "tmux -CC attach -t \(controller.sessionName)"
-        )
-    }

Also applies to: 201-212

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

In `@Sources/Workspace.swift` around lines 154 - 155, The current logic sets
effectiveIncludeScrollback = tmuxControllerId == nil && includeScrollback which
disables scrollback whenever a tmuxControllerId exists even if tmuxInfo lookup
failed; change the condition to include scrollback when includeScrollback is
true and either there's no tmux controller or the controller lookup produced no
tmuxInfo (i.e. effectiveIncludeScrollback = includeScrollback &&
(tmuxControllerId == nil || tmuxInfo == nil)). Update the same conditional logic
in the other occurrences referenced (around the 201–212 block) so snapshots
retain scrollback when tmuxInfo is nil and ensure reconnect metadata handling is
still preserved.

@cstoneham

Copy link
Copy Markdown

+1 on this feature, as a heavy tmux user this would be great

@teamleaderleo teamleaderleo added S2: major A crash, hang, lost state, broken connection, or a regression on a path people use area: remote cmux ssh, remote daemon, tunnels, device pairing labels Sep 30, 2026
@teamleaderleo

Copy link
Copy Markdown
Collaborator

The repository already has remote tmux control mode (#5553), but this PR proposes a broader native integration; leaving open for scope and product review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: remote cmux ssh, remote daemon, tunnels, device pairing S2: major A crash, hang, lost state, broken connection, or a regression on a path people use

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants