feat: tmux control mode integration — session picker, layout sync, reconciler#2690
feat: tmux control mode integration — session picker, layout sync, reconciler#2690evchee wants to merge 1 commit intomanaflow-ai:mainfrom
Conversation
|
@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
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (13)
📝 WalkthroughWalkthroughThis 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
937e245 to
b0f134b
Compare
Greptile SummaryThis PR wires up full tmux control-mode integration: a recursive layout-tree parser (
Confidence Score: 4/5Safe 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
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "feat: tmux control mode — session picker..." | Re-trigger Greptile |
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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
}| .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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
Summary
TmuxLayoutNode/TmuxLayoutparser replaces flat pane-id extraction — preserves split direction, geometry, and zoom flagTmuxLayoutReconcilerwithTmuxReconcilerWorkspaceprotocol seam; orphan-based move-pane handling (break-pane/join-panepreserves panels); window-scoped pending panel FIFO eliminates race between concurrent%layout-changeevents%layout-changeregardless of zoom state;isZoomedexposed for display only%window-pane-changednow callsfocusPanel(was only logging)%pane-mode-changed,%paste-buffer-changed,%client-session-changedparsed instead of droppedtmux.control.subscribe/sendRPC and pump goroutine deleted; direct SSHtmux -CCinWorkspace.swiftis the only live pathtmuxExactTargetconsistency: All session-targeting handlers use exact-match prefix (tmux ≥2.5)remoteConnectionState = .disconnectedTest plan
TmuxControlParserTests— layout tree parsing, zoom flag, all protocol message typesTmuxLayoutReconcilerTests— fresh attach, pane removal, zoom reconciliation, move-pane preservation, window-scoped pending panels, user dismiss, window close, resettmuxVersionAtLeastedge cases,isValidNewTmuxNamevalidationbreak-paneacross windows → panel moves, not closed and recreatedSummary by cubic
Integrates native
tmuxcontrol 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
tmux -CCwith full layout tree parsing (splits, geometry, zoom).break-pane/join-paneand dedupes via a window-scoped pending FIFO.%window-pane-changed; zoom no longer blocks reconciliation.cmuxd-remoteadapter RPCs:tmux.probe,tmux.session.list/ensure,tmux.pane.{new,list,exists}.Refactors
tmux.control.subscribe/sendRPC; use direct SSHtmux -CConly.TmuxReconcilerWorkspaceseam for testable panel management.Written for commit 937e245. Summary will update on new commits.
Summary by CodeRabbit
Release Notes
New Features
Tests
Chores