Skip to content

feat: tmux control mode integration — session picker, layout sync, reconciler#2690

Closed
evchee wants to merge 1 commit intomanaflow-ai:mainfrom
evchee:feat-tmux-adapter
Closed

feat: tmux control mode integration — session picker, layout sync, reconciler#2690
evchee wants to merge 1 commit intomanaflow-ai:mainfrom
evchee:feat-tmux-adapter

Conversation

@evchee
Copy link
Copy Markdown

@evchee evchee commented Apr 7, 2026

Summary

  • Session picker: List and attach to existing remote tmux sessions from the UI
  • Full layout tree: Recursive TmuxLayoutNode/TmuxLayout parser replaces flat pane-id extraction — preserves split direction, geometry, and zoom flag
  • Reconciler rewrite: TmuxLayoutReconciler with TmuxReconcilerWorkspace protocol seam; orphan-based move-pane handling (break-pane/join-pane preserves panels); window-scoped pending panel FIFO eliminates race between concurrent %layout-change events
  • Zoom fix: Removed skip guard — reconciliation always runs on %layout-change regardless of zoom state; isZoomed exposed for display only
  • Focus wired: %window-pane-changed now calls focusPanel (was only logging)
  • New protocol stubs: %pane-mode-changed, %paste-buffer-changed, %client-session-changed parsed instead of dropped
  • Dead transport removed: tmux.control.subscribe/send RPC and pump goroutine deleted; direct SSH tmux -CC in Workspace.swift is the only live path
  • tmuxExactTarget consistency: All session-targeting handlers use exact-match prefix (tmux ≥2.5)
  • Watchdog escalation: Silence timeout sets remoteConnectionState = .disconnected

Test plan

  • 37 TmuxControlParserTests — layout tree parsing, zoom flag, all protocol message types
  • 16 TmuxLayoutReconcilerTests — fresh attach, pane removal, zoom reconciliation, move-pane preservation, window-scoped pending panels, user dismiss, window close, reset
  • 2 Go adapter tests — tmuxVersionAtLeast edge cases, isValidNewTmuxName validation
  • Attach to existing tmux session → panels appear matching remote layout
  • Kill a pane in tmux → corresponding panel closes
  • Zoom a window, add/remove pane while zoomed → reconciliation runs correctly
  • break-pane across windows → panel moves, not closed and recreated
  • Rename session in tmux → UI updates

Summary by cubic

Integrates native tmux control mode with a session picker and live layout sync so remote panes mirror exactly in the app. Rewrites reconciliation, removes the old transport, and fixes focus/zoom handling for reliable remote sessions.

  • New Features

    • Session picker to list/attach/create sessions; persists across reconnects.
    • Live layout sync via tmux -CC with full layout tree parsing (splits, geometry, zoom).
    • New reconciler preserves panels on break-pane/join-pane and dedupes via a window-scoped pending FIFO.
    • Focus updates on %window-pane-changed; zoom no longer blocks reconciliation.
    • cmuxd-remote adapter RPCs: tmux.probe, tmux.session.list/ensure, tmux.pane.{new,list,exists}.
    • Exact session targeting and a watchdog that disconnects on silence.
  • Refactors

    • Removed dead tmux.control.subscribe/send RPC; use direct SSH tmux -CC only.
    • Added TmuxReconcilerWorkspace seam for testable panel management.
    • Build fix: added Sentry symbolizer stub and wired into Xcode to resolve missing symbol.

Written for commit 937e245. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added tmux session picker UI for selecting or creating new tmux sessions
    • Implemented tmux session persistence and auto-reattach across reconnects
    • Added tmux pane-to-panel synchronization and management
    • Added localization support for tmux UI (English and Japanese)
  • Tests

    • Added comprehensive test coverage for tmux control parsing and layout reconciliation
  • Chores

    • Updated build configuration to support tmux functionality

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 7, 2026

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

A member of the Team first needs to authorize it.

…r rewrite

- Session picker: list and attach to existing remote tmux sessions from the UI
- Full recursive layout tree parser (TmuxLayoutNode/TmuxLayout) replacing flat
  pane-id extraction; geometry, split direction, and zoom flag all preserved
- TmuxLayoutReconciler rewritten with TmuxReconcilerWorkspace protocol seam,
  orphan-based move-pane handling, window-scoped pending panel FIFO, and
  deferred purge task for break-pane/join-pane without panel recreation
- Zoom reconciliation fix: removed skip guard — always reconcile on %layout-change
  regardless of zoom state; isZoomed exposed for display only
- %window-pane-changed now calls focusPanel (was only logging)
- New protocol stubs: %pane-mode-changed, %paste-buffer-changed, %client-session-changed
- Dead daemon tmux.control.subscribe/send transport removed (was unused; direct
  SSH tmux -CC in Workspace.swift is the live path)
- tmuxExactTarget consistency: all session-targeting handlers now use exact-match prefix
- Watchdog escalation: silence timeout sets remoteConnectionState = .disconnected
- TmuxSessionPickerSheet moved to Sources/Panels/
- 37 TmuxControlParserTests + 16 TmuxLayoutReconcilerTests + 2 Go adapter tests
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c9808f3-e8f2-4261-ae89-0d2bcd63741e

📥 Commits

Reviewing files that changed from the base of the PR and between 2669b6d and b0f134b.

📒 Files selected for processing (13)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Localizable.xcstrings
  • Sources/Panels/TmuxSessionPickerSheet.swift
  • Sources/SentrySymbolizerStub.c
  • Sources/TmuxControlParser.swift
  • Sources/TmuxLayoutReconciler.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceContentView.swift
  • cmuxTests/TmuxControlParserTests.swift
  • cmuxTests/TmuxLayoutReconcilerTests.swift
  • daemon/remote/cmd/cmuxd-remote/main.go
  • daemon/remote/cmd/cmuxd-remote/tmux_adapter.go
  • daemon/remote/cmd/cmuxd-remote/tmux_adapter_test.go

📝 Walkthrough

Walkthrough

This PR adds end-to-end tmux support to cmux, including a tmux control-mode parser, layout reconciler, session picker UI, workspace integration for session persistence across reconnects, and remote daemon RPC handlers for tmux operations (probe, list sessions, manage panes, stream control events).

Changes

Cohort / File(s) Summary
Tmux Control Mode Parsing
Sources/TmuxControlParser.swift, cmuxTests/TmuxControlParserTests.swift
Introduced line-by-line tmux control event parser supporting layout changes, window/session lifecycle, pane mode, and exit events. Includes recursive layout tree parser handling pane geometry and horizontal/vertical splits, plus utilities to extract sorted pane IDs and detect zoomed state. Comprehensive test coverage validates event parsing, layout tree construction, and edge cases (checksums, nesting, malformed input).
Tmux Layout Reconciliation
Sources/TmuxLayoutReconciler.swift, cmuxTests/TmuxLayoutReconcilerTests.swift
Implemented @MainActor reconciler tracking bidirectional tmux pane↔panel mappings and deferred orphan cleanup. Supports panel adoption on pane moves (break/join), immediate closure on window close, user-initiated split claiming, and removal tracking to suppress re-creation. Protocol-abstracted workspace integration enables panel creation/closure. Extensive test suite validates reconciliation logic, zoom handling, window scoping, and lifecycle edge cases.
Tmux Session Picker UI
Sources/Panels/TmuxSessionPickerSheet.swift, Sources/WorkspaceContentView.swift
Added SwiftUI sheet for tmux session selection with input validation (alphanumeric/dash/underscore), submission state management to prevent double-submit, error recovery via workspace status monitoring, and "Skip" action for opt-out. Sheet renders session metadata (window count, attached status) and wires text field submission and create/skip buttons to workspace actions.
Workspace Integration
Sources/Workspace.swift
Major additions: remote tmux session discovery and version probing, persistent session state across reconnects (name, ID, version, UTF-8), control-mode subprocess with UTF-8 safe buffering and watchdog stall detection, reconciler initialization and event handling, tmux pane→panel split creation with pending panel tracking, and tmux-aware terminal startup command generation. Extended newTerminalSplit() with optional tmuxPaneId parameter.
Localization
Resources/Localizable.xcstrings
Added 10 new localization keys for tmux session picker UI (tmux.picker.title, tmux.picker.subtitle, tmux.picker.existing, etc.) with English and Japanese translations, including pluralized window count formatting via %lld.
Build Configuration
GhosttyTabs.xcodeproj/project.pbxproj
Wired new tmux source files (SentrySymbolizerStub.c, TmuxSessionPickerSheet.swift, TmuxControlParser.swift, TmuxLayoutReconciler.swift) and test files (TmuxControlParserTests.swift, TmuxLayoutReconcilerTests.swift) into main and test build phases.
Sentry Symbolizer Stub
Sources/SentrySymbolizerStub.c
Added C implementation of sentry__symbolize() using dladdr() to resolve addresses into dynamic loader symbol information, populate frame info struct, and invoke caller-provided callback.
Remote Daemon RPC Handlers
daemon/remote/cmd/cmuxd-remote/main.go, daemon/remote/cmd/cmuxd-remote/tmux_adapter.go, daemon/remote/cmd/cmuxd-remote/tmux_adapter_test.go
Implemented tmux RPC capability advertisement and method dispatch for probe, session list/ensure, pane create/list/exists, and control-mode streaming. Adapter enforces command timeouts, gates features on tmux version, computes exact session targets to prevent name parsing ambiguity, and standardizes error responses. Test coverage validates version detection and session name validation logic.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Workspace
    participant Daemon as Remote Daemon
    participant Tmux
    participant Reconciler
    participant PanelSystem as Panel System

    User->>Workspace: connectRemote()
    Workspace->>Daemon: tmux.probe
    Daemon->>Tmux: tmux -V
    Tmux-->>Daemon: version info
    Daemon-->>Workspace: version + UTF8 support
    
    Workspace->>Daemon: tmux.session.list
    Daemon->>Tmux: tmux list-sessions
    Tmux-->>Daemon: sessions with metadata
    Daemon-->>Workspace: remoteTmuxSessions array
    
    alt User selects session
        User->>Workspace: selectTmuxSession(name)
        Workspace->>Daemon: tmux.session.ensure
        Daemon->>Tmux: attach-session -t name
        Daemon-->>Workspace: session confirmed
    else User skips picker
        User->>Workspace: skipTmuxPicker()
        Workspace->>Workspace: suppress picker
    end
    
    rect rgba(100, 150, 200, 0.5)
        Note over Workspace,Tmux: Control Mode Streaming
        Workspace->>Daemon: tmux.control-subscribe
        loop Line-by-line events
            Daemon->>Tmux: tmux -CC attach-session
            Tmux-->>Daemon: %layout-change ...
            Daemon-->>Workspace: TmuxControlEvent
        end
    end
    
    Workspace->>Reconciler: apply(layoutChangeEvent)
    loop For each pane
        Reconciler->>PanelSystem: newTerminalSplitForTmuxPane()
        PanelSystem-->>Reconciler: panelId
        Reconciler->>Reconciler: track paneId↔panelId
    end
Loading
sequenceDiagram
    participant Tmux as Tmux Session
    participant Daemon as Remote Daemon
    participant Parser as TmuxControlParser
    participant Workspace
    participant Reconciler as TmuxLayoutReconciler
    participant Panels as Panel System

    Tmux->>Daemon: %layout-change window-id layout-string flags
    Daemon->>Parser: parseLine(raw)
    Parser->>Parser: tokenize + dispatch
    Parser->>Parser: parseLayoutTree()
    Parser-->>Daemon: TmuxControlEvent.layoutChange
    
    Daemon-->>Workspace: TmuxControlEvent via RPC
    Workspace->>Reconciler: apply(event)
    
    rect rgba(200, 100, 100, 0.5)
        Note over Reconciler,Panels: Reconciliation Phase
        Reconciler->>Reconciler: extract paneIds from layout
        
        loop For new/moved panes
            Reconciler->>Panels: newTerminalSplitForTmuxPane(paneId)
            Panels-->>Reconciler: panelId or nil
            Reconciler->>Reconciler: update paneId↔panelId mapping
        end
        
        loop For removed panes
            alt Pane removed (window still active)
                Reconciler->>Reconciler: defer orphan cleanup
            else Window closed
                Reconciler->>Panels: closePanel(panelId, force: true)
            end
        end
    end
    
    Note over Reconciler: Schedule deferred purge<br/>after run-loop cycle
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

A hopper hops through tmux's grand tree,
Parsing layouts wild and splits so free, 🌳
Reconciling panes with panel grace,
Each new window finds its perfect place,
Control mode streams and sessions gleam—
The terminal's collaborative dream! ✨

✨ 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

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

@evchee evchee force-pushed the feat-tmux-adapter branch from 937e245 to b0f134b Compare April 7, 2026 16:36
@evchee evchee closed this Apr 7, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 7, 2026

Greptile Summary

This PR wires up full tmux control-mode integration: a recursive layout-tree parser (TmuxControlParser), a window-scoped reconciler (TmuxLayoutReconciler) with orphan-based move-pane preservation, a session picker sheet, and a persistent SSH tmux -CC subprocess with watchdog escalation in WorkspaceRemoteSessionController. The sequencing of applyRemoteTmuxSessionstartTmuxControlMode via two back-to-back DispatchQueue.main.async blocks is correct and prevents stale events from reaching the reconciler before it is ready.

  • The break-pane panel-preservation guarantee in schedulePurgeIfNeeded relies on both %layout-change events arriving before the deferred purge Task runs; events that span separate pipe reads on slow connections can race with the purge and degrade to a panel close-and-recreate.
  • tmuxExactTarget in tmux_adapter.go spawns tmux -V on every RPC call — the version result should be cached.
  • isSubmitting in TmuxSessionPickerSheet can get stuck if the controller returns early without publishing a daemon error.

Confidence Score: 4/5

Safe to merge; all findings are P2 edge-case issues with no impact on the primary attach and reconcile path.

Three P2 findings: (1) break-pane purge timing is fragile when events span separate pipe reads on high-latency connections; (2) tmux -V spawned per RPC call; (3) isSubmitting can get stuck on a concurrent disconnect edge case. None affect the happy path. Score is 4 because the isSubmitting stuck-UI is a present defect on a reachable (if uncommon) code path, and the purge-timing issue degrades a documented feature guarantee.

Sources/TmuxLayoutReconciler.swift (purge timing), Sources/Panels/TmuxSessionPickerSheet.swift (isSubmitting), daemon/remote/cmd/cmuxd-remote/tmux_adapter.go (version caching)

Important Files Changed

Filename Overview
Sources/TmuxLayoutReconciler.swift New reconciler: clean bidirectional pane→panel mapping with orphan/adopt pattern for break-pane; purge timing is fragile when events span separate pipe reads
Sources/TmuxControlParser.swift New parser: robust recursive-descent layout tree with checksum skipping; all 12 protocol events handled; comprehensive test suite
Sources/Workspace.swift Adds session provisioning, control-mode SSH subprocess, watchdog escalation, and reconciler integration; two-block main-queue sequencing for attach→start is correctly ordered
Sources/Panels/TmuxSessionPickerSheet.swift New picker sheet: double-submit guard via isSubmitting; can get permanently stuck if controller returns early without publishing a daemon error
daemon/remote/cmd/cmuxd-remote/tmux_adapter.go New tmux RPC handlers with exact-match session targeting; tmuxExactTarget spawns tmux -V per invocation and should be cached
daemon/remote/cmd/cmuxd-remote/main.go Removed dead tmux.control.subscribe transport; added tmux RPC method routing; clean handler dispatch table
cmuxTests/TmuxControlParserTests.swift 37 tests covering all protocol message types, layout tree parsing, checksum skipping, and zoom flag detection
cmuxTests/TmuxLayoutReconcilerTests.swift 16 tests covering fresh-attach, pane removal, zoom, synchronous move-pane preservation, user dismiss, windowClose, and reset

Sequence Diagram

sequenceDiagram
    participant SSH as SSH pipe
    participant Queue as Controller Queue
    participant Main as Main Actor
    participant Rec as TmuxLayoutReconciler
    participant WS as Workspace

    SSH->>Queue: readabilityHandler (data chunk)
    Queue->>Queue: consumeTmuxControlData()
    Queue->>Main: async layoutChange @A
    Queue->>Main: async layoutChange @B
    Main->>WS: applyTmuxControlEvent(.layoutChange @A)
    WS->>Rec: reconcile(@A) — orphan %2
    Rec->>Main: Task purgeOrphans (enqueued)
    Main->>WS: applyTmuxControlEvent(.layoutChange @B)
    WS->>Rec: reconcile(@B) — adopt %2 from orphans
    Main->>Rec: purgeOrphans (nothing left)
    Note over Rec: ✓ panel preserved (same-pipe-read path)

    Note over SSH,Rec: ⚠ If events arrive in separate reads,<br/>purge may fire before @B is dispatched
Loading

Reviews (1): Last reviewed commit: "feat: tmux control mode — session picker..." | Re-trigger Greptile

Comment on lines +231 to +241
private func schedulePurgeIfNeeded(workspace: any TmuxReconcilerWorkspace) {
guard !purgePending, !orphanedPanes.isEmpty else { return }
purgePending = true
// Capture the workspace reference for the async closure.
// The workspace is a long-lived object; the weak capture is a safety net.
Task { @MainActor [weak self, weak workspace] in
guard let self, let workspace else { return }
self.purgePending = false
self.purgeOrphans(workspace: workspace)
}
}
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 Purge timing is fragile across pipe-read boundaries

The break-pane panel-preservation guarantee holds only when both %layout-change events arrive in the same consumeTmuxControlData call (same pipe read). If the two events are split across separate readability-handler callbacks — possible on high-latency or congested SSH connections — the deferred Task { @MainActor } is enqueued after event A finishes, potentially before event B's DispatchQueue.main.async block reaches the unified executor. The purge fires first, closing the panel before window B's layout-change can adopt it, degrading break-pane to a close-and-recreate instead of a panel preserve.

The unit test covers only the synchronous case (both apply() calls before any yield), which always works. Consider adding a short wall-clock delay to the purge so the grace window is measured in real time rather than cooperative-yield ordering.

Comment on lines +29 to +42
func tmuxExactTarget(name string) string {
out, err := tmuxOutput("-V")
if err != nil {
return name // can't probe version; use raw name as safe fallback
}
version := strings.TrimSpace(string(out))
if parts := strings.SplitN(version, " ", 2); len(parts) == 2 {
version = parts[1]
}
if tmuxVersionAtLeast(version, 2, 5) {
return "=" + name
}
return name
}
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 tmux -V subprocess spawned on every call

tmuxExactTarget runs tmux -V each time it is invoked, adding a subprocess round-trip to every tmux.session.ensure, tmux.pane.new, tmux.pane.list, and tmux.pane.exists RPC call. Since the tmux version cannot change while the daemon is running, the result should be cached:

var tmuxVersionOnce sync.Once
var tmuxVersionCached string

func cachedTmuxVersion() string {
    tmuxVersionOnce.Do(func() {
        if out, err := tmuxOutput("-V"); err == nil {
            v := strings.TrimSpace(string(out))
            if parts := strings.SplitN(v, " ", 2); len(parts) == 2 {
                v = parts[1]
            }
            tmuxVersionCached = v
        }
    })
    return tmuxVersionCached
}

Comment on lines +122 to +128
.onChange(of: workspace.remoteDaemonStatus) { status in
// If the daemon reports an error after we submitted, the attach failed.
// Reset isSubmitting so the user can retry or choose a different session.
if isSubmitting, status.state == .error {
isSubmitting = false
}
}
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 isSubmitting can get permanently stuck on a silent early-return

isSubmitting is only cleared when remoteDaemonStatus.state == .error. Workspace.selectTmuxSession can return early without publishing any daemon error if remoteSessionController is nil or if the controller's daemonRemotePath is nil at dispatch time (e.g., a concurrent disconnect between discovery and the user tapping Create/Select). In that path isSubmitting stays true indefinitely, leaving all picker buttons permanently disabled until the sheet is closed and reopened.

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