Skip to content

Commit b405287

Browse files
Freeform 2D canvas layout for workspace panes (#5987)
* CmuxCanvas: pure canvas layout model package Pane frames, canonical gap metrics, drag/resize snapping with guides, align/distribute/tidy commands, spatial focus navigation, new-pane placement, and viewport math. Foundation-only, deterministic, 57 tests. Part of the freeform canvas layout for workspace panes (design in docs/canvas-layout-design.md). * Canvas layout: AppKit rendering layer + workspace integration NSScrollView-based freeform canvas (CanvasRootView) hosting terminal surfaces directly (portal-detached, no edge reflow) and other panel kinds via NSHostingView. Pane chrome with title-bar move drag, edge/ corner resize with snap guides, explicit offscreen occlusion lifecycle, document auto-sizing with origin compensation, overview zoom, reveal. Workspace gains layoutMode + canvasModel; canvas-aware moveFocus; WorkspaceContentView branches per mode. * Canvas: shortcut actions, AppDelegate dispatch, palette commands, View menu 12 new KeyboardShortcutSettings actions (4 bound: ctrl+cmd+C/R/O/T, 8 unbound align/distribute/equalize), all routed through the shared CanvasActionExecutor; palette commands gated on canvas mode via a new workspace.canvasLayout context key; View menu entries. * Canvas: canvas.* socket domain + cmux canvas CLI namespace New ControlCanvasContext seam slice (info/set_mode/set_frame/align/ reveal/overview) lifted onto the coordinator; TerminalController witnesses route through CanvasActionExecutor so the socket shares the single execution path. CLI: cmux canvas info|mode|set-frame|align| reveal|overview with --workspace/--surface ref normalization. * Canvas: session persistence, CmuxSettings ShortcutAction parity, localization SessionWorkspaceSnapshot gains layoutMode + canvasPanes (z-ordered, panel-id remapped on restore; restore sets layoutMode directly to skip seed-from-splits). Package ShortcutAction mirrors the 12 canvas actions (cmux.json bindings + Settings UI defaults). 17 new Localizable.xcstrings keys translated for all 19 locales. * Canvas: web docs category, coordinator canvas tests, window/canvas test stubs Shortcut docs + configuration docs render the new canvas category from cmux-shortcuts.ts (en/ja messages added). 8 coordinator tests cover the canvas domain wire contract; stub file gains window + canvas defaults. * Canvas: center overview zoom explicitly setMagnification(centeredAt:) lands off-center on large magnification deltas; anchor by setting the clip bounds origin to contentCenter - clipSize/2 in the same animation group. * Canvas: extract AppKit rendering layer into CmuxCanvasUI package Sources/Canvas kept only app glue (Workspace integration, panel content mounts, palette/menu/socket surfaces). The package owns the scroll view, document, pane chrome, drag/resize sessions, lifecycle, and CanvasModel, behind three seams: CanvasPaneContentMounting (panel content), CanvasTheme (colors), and pre-localized CanvasPaneChrome strings. 7 CanvasModel tests cover sync/seed/restore/z-order/alignment. * Canvas: address review findings - web shortcut docs: canvas category title/blurb added to all 18 remaining locale catalogs (greptile P1) - package ShortcutAction canvas display names localized via the existing shortcut.*.label xcstrings keys (coderabbit) - canvas.reveal with no surface and no focused panel returns a dedicated invalid_state error instead of minting a random UUID (coderabbit) - unit test covering canvas-mode moveFocus spatial routing (coderabbit) Not changed: setOcclusion polarity — the parameter is named 'visible' (ghostty_surface_set_occlusion(surface, visible)), so setRendering maps correctly; reviewer inferred inverted semantics from the method name. * Canvas: Cmd+Scroll panning + zoom in/out/reset Cmd+Scroll routes scroll events to the canvas via a local event monitor regardless of what is under the cursor, so terminal scrollback no longer interrupts panning. Zoom in/out (1.25x steps) and actual-size, anchored at the viewport center, through the full shared pipeline: shortcuts (opt+cmd+= / opt+cmd+- / opt+cmd+0), palette, canvas.zoom socket verb, cmux canvas zoom CLI, ShortcutAction mirror, 19-locale labels, web docs. * Canvas: fix stranded/blank browser webviews Two root causes. (1) The window portal only re-synced webview geometry on split-layout events; canvas scrolls/zooms/drags moved anchors with no such event, leaving webviews at stale window coordinates. CanvasRootView now fires a coalesced onViewportGeometryChanged callback from every geometry path (scroll, zoom, drag frame, layout, animated alignment) and the host nudges BrowserWindowPortalRegistry.scheduleExternalGeometry- Synchronize. (2) Workspace.renderedVisiblePanelIdsForCurrentLayout was purely bonsplit-based, so focusing another canvas pane deselected the browser's bonsplit tab and force-hid its webview (visibleInUI=0, never restored). Canvas mode now reports every panel as rendered (the portal's frame clamp handles offscreen), and setLayoutMode reconciles portal visibility immediately in both directions. Verified: reveal/tidy/overview round-trips keep webview content glued to its pane; splits mode unaffected. * Canvas: gap + snapping settings, typed canvas.zoom direction canvas.paneGap (0-64, default 16) and canvas.snappingEnabled join the settings catalog (CanvasCatalogSection), the Settings UI (App section: stepper + toggle, localized for 19 locales), cmux.json (supported paths, web schema + en/ja schema descriptions). CanvasLayoutSettings accepts any numeric UserDefaults representation since the catalog writes Int. Verified live: gap=40 changes tidy pitch to 680/460. canvas.zoom direction crosses the seam as a typed ControlCanvasZoomDirection so the app witness switch is exhaustive (review finding: unknown strings previously fell through to reset). * Canvas: tabbed-pane model (CmuxCanvas) + persistence shape CanvasPane now hosts ordered panelIds + selectedPanelId (CanvasPanelID distinct from CanvasPaneID; single-tab panes reuse the founding panel's UUID so existing behavior and persistence stay identical). CanvasLayout gains tab operations: pane(containing:), addPanel (join/move primitive), removePanel (pane dies with last tab, selection moves to neighbor), breakOutPanel, selectPanel. CanvasModel resolves all panel-keyed APIs through hosting panes (frame/setFrame/bringToFront/snap exclusion/ spatial focus -> neighbor's selected tab) and adds joinPanel/breakOut- Panel/selectPanel. Session snapshots carry panelIds + selectedPanelId (pre-tab snapshots decode as single-tab panes; pane identity follows the first surviving panel through the restore id remap). 12 new tests across both packages; single-tab canvas behavior verified unchanged on the tagged build. UI tab strip + join/break surfaces come next. * Canvas: tab strip UI + canvas.join/break/select_tab verbs Pane views are now keyed by CanvasPaneID and host a SwiftUI tab strip (single tab reads as a title bar; multiple tabs render selectable pills with hover close). One content mount per pane — exactly the selected tab — switching unmounts/remounts through the existing seam. The root reconciles panes+mounts+chrome from cached descriptors on external model mutations, so socket verbs update the UI without a SwiftUI pass. focusPanel selects the tab in canvas mode so focus and visibility never diverge. New verbs: canvas.join {surface,target_surface}, canvas.break, canvas.select_tab; CLI: cmux canvas join/break/select-tab; 2 new coordinator tests (146 green). Preflight on tagged build: join renders a two-tab strip, select-tab switches mounted terminals (scrollback intact), break returns the tab to its own pane. * Canvas: browser focus ring, frame-synced webview tracking, blank-webview fix Three dogfood-reported browser flaws: - Focus ring occluded: window-portal webviews float above the pane's layer border; hosted canvas content now insets 2px so the ring stays visible (terminals unaffected, they render under the layer border). - Webview lagging pane resize: onViewportGeometryChanged now syncs each browser anchor synchronously (BrowserWindowPortalRegistry.synchronize- ForAnchor) per drag/scroll frame, with the coalesced scheduled pass as settle-up, matching split-divider per-frame behavior. - Webviews going blank: the hidden-webview discard (300s) fires when panel-level visibility flips false (stale bonsplit pane ownership under canvas mode) and nothing on the canvas path ever restored. The canvas mount now drives panel-level webview visibility directly: mount/render -> visible (restores a discarded webview), occlude/unmount -> hidden (discard timer may reclaim offscreen browsers, correctly). * Canvas: satisfy Swift file-length budget gate Split CanvasRootView into core + PaneGestures + Viewport extension files (617 -> 394/101/125; gesture and viewport conformances are coherent units; touched members widened private -> internal within the package). Split the window-domain test stubs out of the over-threshold stub file. Budget bumps for in-place feature growth that belongs where it is: CLI canvas namespace, shortcut enum cases, settings rows, Workspace hooks, persistence snapshot fields, unit test (+17/+12/+12/+7 app entry hooks). * Canvas: tab cycling via next/prev-surface shortcuts + tabs in canvas.info selectNextSurface/selectPreviousSurface branch to canvas-pane tab cycling (wrapping, focus follows) when the focused pane has multiple tabs, falling back to bonsplit semantics otherwise — cmd+shift+]/[ now does the expected thing on a tabbed canvas pane. canvas.info panes gain surface_ids/surface_refs + selected_surface_id/ref, and the focused flag uses containment so multi-tab panes report correctly. Verified live: shortcut cycling flips selection with focus, and tab groups persist across app relaunch. * Canvas: Cmd+T opens a tab in the focused canvas pane newTerminalSurfaceInFocusedPane joins the new panel into the focused panel's canvas pane (sync first so the panel exists in the model), so Cmd+T matches workspace-pane tab semantics; Cmd+D / Cmd+Shift+D remain the new-floating-pane shortcuts (split path -> placer). Verified live: focused pane [surface:3] -> [surface:3, surface:23] selected. Also: groundwork for inline browser hosting on canvas (environment flag cmuxCanvasInlineBrowserHosting wired into BrowserPanelView's hosting decision) to fix webview content trailing during pans at the root. Parked default-off: the inline slot mis-lays out under the canvas's flipped document hierarchy (small offset rect); per-frame portal anchor sync remains until that layout path is fixed. * Canvas: portal/inline hosting ownership flag (inline still parked) BrowserPanel.canvasInlineHostingActive marks webviews owned by a canvas pane's inline host; the workspace portal-visibility reconciler and the canvas geometry sync skip such panels so they cannot rebind the webview into the window portal (this was the inline-hosting tug-of-war: 4 stray portal binds after inline takeover). With the war fixed, inline content renders live and stable, but the webview frame inside the slot still lays out partial-width/right-aligned, so the env flag stays default-off for dogfood; next step is the pinHostedWebView frame-reset path under the canvas hierarchy. * Canvas: runtime debug flag + frame diagnostics for inline browser hosting canvasInlineBrowserHostingDebug (launch-time UserDefaults bool) flips inline hosting on for diagnosis without a rebuild; the inline update path logs host/slot/webview frames, inspector presence, and companions (DEBUG only). Diagnosis so far: pinning is correct per host (web == slot == host bounds once settled), no phantom inspector split — the misplacement comes from MULTIPLE live BrowserPanelView hosts competing for one webview (orphan fragments render at dead hosts' positions). Next: audit canvas mount lifecycle for double-mounts and the browser-open placement=reuse flow. Flag remains default-off. * Canvas: focused pane raises to front; portal settle-refresh + z-order Focusing a pane through ANY entrypoint now brings it to the front: click focus raises immediately in the gesture path, and sync() raises the pane hosting focusedPanelId (covers keyboard, palette, socket, and webview-click focus). Browser portal z-priorities mirror canvas z-order (front pane's webview stacks above a back pane's), re-derived on every layout change. New onViewportSettled callback (didEndLiveScroll/Magnify) force-refreshes every portal-hosted browser so content cannot rest at a stale frame after a pan — addressing dogfood screenshots where page content stayed window-fixed while its pane scrolled. Verified live: focusing back pane surface:8 moved it to the z-order front. Known residual: during the pan itself, portal content still trails (inherent to window-portal hosting; inline hosting work continues). * Canvas: pin hosted panel content with constraints (root cause of shifted webviews) NSHostingView self-sizes to SwiftUI's ideal size under autoresizing (sizingOptions), so hosted canvas content could settle at a small intrinsic rect instead of filling its pane. The browser portal anchors live inside that hosting view, so webview content rendered at the wrong rect in BOTH hosting modes — this is the mechanism behind dogfood screenshots where page content sat shifted/partial relative to the pane. Hosted mounts now pin to the content container with constraints and set sizingOptions = []; terminal mounts keep frame+autoresizing. Verified inline-flag run: focused browser pane fills edge-to-edge. * Merge origin/main; reconcile budget for main-side untracked growth TerminalNotificationStore.swift (+96, grown on main by #5651 without a budget bump) and FeedCoordinator.swift (+16) exceed the budgets recorded on main itself — main's gate is currently red independent of this branch. Bump both here so this PR's merge CI reflects only its own debt. * Canvas: inline browser hosting ON by default With the hosting-view self-sizing root cause fixed (a106b6b) and the portal ownership war resolved (50f7adf), inline hosting now verifies clean: webviews fill their panes pixel-perfect at 1x, multiple browser panes render simultaneously with correct clipping, z-order stacks natively (focused pane's browser above a back pane's), content scales with overview/zoom magnification, and pans move content in the same CoreAnimation transaction as the pane — the window-portal trailing Aziz reported is structurally gone. Launch-time default canvasInlineBrowserHostingDisabled reverts to portal hosting if needed. * Canvas: dragging a multi-tab pane by its tab strip works Tab pills are SwiftUI Buttons and consume mouse-down, so the AppKit title-bar drag path never engaged on panes with several tabs (dogfood: 'can't really drag a multi-tab pane'). Each pill now carries a simultaneous DragGesture (4pt threshold) that relays its translation to the same pane-drag delegate path (snap + guides + Command suspend included); taps still select, hover-close still works. Translation is pane-local, which equals document points at any magnification. * Canvas: tab bar matches workspace split pane tabs Rebuilt the canvas pane strip to bonsplit's tab anatomy (TabBarMetrics parity): 30pt bar, full-height square tabs with 1px trailing separators, selected/hover rectangle fills, 14pt icon slot that swaps to a 16pt close button on hover, 11pt middle-truncated titles, 220pt max tab width, active/inactive text alphas 0.82/0.62. Single-tab panes render as a real tab bar too, matching split panes. Tab drags still relay into the pane-drag path; clicks select; hover-close closes. * Canvas: tab strip events back on the AppKit path (drag regression fix) The simultaneous SwiftUI DragGesture on tab pills made dragging fight the Button recognizers and feel slow (gesture-system delivery vs NSEvent tracking), and the nested hover-close Button got swallowed — dogfood: 'pane fighting against me', 'sometimes can't close tabs'. The strip is now render-only SwiftUI: tabs report their tab/close hit rects via PreferenceKey, CanvasPaneView owns ALL strip mouse events (hitTest claims the title-bar region), routes drags through the original fast AppKit drag session (snap, guides, autoscroll), and resolves clicks at mouse-up against the reported rects (close beats select; sub-threshold movement still counts as a click). Close hit rect is padded for forgiving clicks. * Canvas: fix terminal row duplication under canvas zoom Surface pixel sizing used convertToBacking, which folds ancestor transforms — the canvas NSScrollView magnification — into the backing size: zooming out re-typeset the terminal at a shrunken pixel grid and rendered duplicated prompt rows (dogfood screenshots, reproduced at 3x zoom-out). Pixel size now derives from the window backingScaleFactor only (identical in split mode), so terminals keep their logical pixel density and the magnification scales them purely visually. Verified: deep zoom-out shows single rows everywhere; zoom round-trip + live typing keeps the grid intact. * Canvas: rendered-panel set is each pane's selected tab With tabbed panes, reporting ALL panels as rendered made the terminal window portal float unmounted background-tab terminals at stale frames (chromeless prompt slivers found in regression sweep). The canvas branch of renderedVisiblePanelIdsForCurrentLayout now returns exactly the selected panel of each canvas pane, matching mount reality; this also restores correct hidden-discard eligibility for background-tab browsers and hibernation visibility semantics. Verified: full verb sweep + tidy + overview shows no floating fragments. * Canvas: tab tear-out (Option+drag) and drop-to-join Option+dragging a tab of a multi-tab pane tears it out into its own pane whose tab bar lands under the cursor, and the drag continues seamlessly (sessions now route by session pane, not the event-source view, since AppKit keeps tracking on the mouse-downed view). Dropping a single-tab pane onto another pane's tab bar joins it as a tab there — the canvas twin of bonsplit's tab drop — resolved front-most-first against pane bar rects. Plain drags still move the pane (Aziz's requested behavior); Option is the tear-out modifier so the two never conflict. Gesture paths can't be socket-driven: needs hand verification. * Canvas: Option+drag on a single-tab pane degrades to a pane drag breakOutPanel correctly no-ops on single-tab panes, but the tear-out entry then started no drag session, leaving Option+drag dead. It now falls back to a normal pane-drag session so the modifier never inerts the gesture. * ci: re-trigger workflows (push events dropped during org API throttling) * Budget: account for zoom-fix comments (+6 GhosttyTerminalView) and canvas tab hook (+1 Workspace) * Canvas: corner resize cursors, viewport restore on workspace switch, toolbar mode toggle Three Aziz dogfood items: - Corner bands now show diagonal resize cursors (NW-SE / NE-SW) via the private window-resize cursors with public fallbacks; matches the existing horizontal/vertical edge cursors. - Switching workspaces away and back restores the exact canvas viewport (center + zoom) instead of snapping to a default. CanvasModel persists savedViewport (canvas coords); the view re-applies it when becoming visible. Fixed via instrumentation: restore must defer until the scroll view is sized (contentSize>1) or it lands at a garbage origin, and saves are suppressed while a restore is pending/in-flight so the programmatic scroll can't overwrite the saved value with a transient. - Discoverable Splits|Canvas toolbar toggle: NSSegmentedControl in WindowToolbarController, selection driven by Workspace.layoutMode and synced via a new .workspaceLayoutModeDidChange notification (covers shortcut/palette/menu/toolbar entrypoints), localized 19 locales. Viewport restore verified: workspace switch away+back lands pixel-identical. * Browser: collapse omnibar accessories into overflow menu at narrow width At narrow pane widths (canvas or split) the browser chrome clipped: the address field vanished and trailing accessory buttons ran off the pane edge. The chrome now measures its width (preference key) and below 420pt collapses the plain-action buttons (focus mode, screenshot, React Grab, dev tools) into a single overflow menu, keeping nav + address field + the popover-anchored profile/theme buttons. Shared with split mode, so narrow splits benefit too. Verified: 340pt pane shows address field + overflow + profile/theme with no clipping; 720pt shows the full row. * Canvas: one-time Command+scroll discovery hint Scrolling inside a pane (content consumes it, canvas doesn't pan) is the teachable moment for Command+scroll panning. After ~1.2s of in-pane scrolling the canvas shows a soft scale+fade pill — 'Command+scroll pans the canvas from anywhere' — once per session, auto-dismissing after ~3.4s. Debounce and dismiss use cancellable Tasks wired to teardown (not the banned asyncAfter). The local scroll monitor distinguishes command-scroll (pans), in-pane plain scroll (hint), and empty-canvas scroll (already pans, no hint). Hint text crosses the package seam pre-localized (19 locales). Command-scroll + hint split into CanvasRootView+CommandScroll.swift to keep the core under the file budget. Gesture-triggered, so needs hand-verification. * Canvas: horizontal scrolling for overflowing pane tabs Tabs in a narrow pane previously spilled past the pane edge. The tab strip now clips to the bar and scrolls horizontally: CanvasPaneView owns the title bar's scrollWheel (it already claims the region for drag/click routing, so a SwiftUI ScrollView can't be used), tracks a clamped offset against the measured content width, and feeds it to the render-only strip. Hit frames are reported in the bar-anchored coordinate space post-offset, so a scrolled-out tab reports a frame outside the bar and clicks stay correct. Dominant-axis delta lets a mostly-vertical trackpad swipe still scroll the strip. Verified: 5 tabs in a 300pt pane stay clipped inside the pane; scroll gesture needs hand-verification. Exact bonsplit color fidelity is the remaining self-contained follow-up. * canvas: port bonsplit tab fill/text colors for visual parity Active/hover tab fills now derive from the pane background using the same lighten-on-dark / darken-on-light treatment as bonsplit's TabBarColors, instead of a flat Color.primary.opacity. Text uses system label colors. Threads barBackground (paneBackground) through CanvasPaneTitleBarView and CanvasPaneTabItem, rebuilding the title bar when the pane background changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * canvas: clear terminal portal on entering canvas to kill ghost artifacts Toggling splits->canvas left split-mode terminal surfaces floating at their old split frames over the canvas (Aziz dogfood), persisting until some later layout event happened to reconcile portal visibility. setLayoutMode reconciled only the browser portal, never the terminal one. Entering canvas now calls hideAllTerminalPortalViews() so the portal layer is cleared immediately; the canvas mount re-shows each visible pane's selected terminal via its portal-detach path. Leaving canvas reconciles to the split-mode rendered set. Relaxed reconcileTerminalPortalVisibility... from private to internal so the canvas extension can call it. Repro (3x rapid splits<->canvas, screenshot with no interaction): green stale terminal block over empty canvas pre-fix; clean post-fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * canvas: trackpad pinch + option-scroll zoom (mouse-friendly) Zoom was keyboard-only. Add two pointer paths that reuse the viewport magnification machinery: - Trackpad pinch: intercept .magnify in the canvas input monitor and forward to scrollView.magnify(with:). Without intercepting, a pane swallows the gesture (Ghostty font-zoom), so pinching over a terminal never zoomed the canvas. Native forwarding anchors at the gesture point and fires didEndLiveMagnify so portals settle through the normal path. - Mouse option+scroll: a mouse has no pinch, so option+scroll zooms toward the cursor via setMagnification(centeredAt:). Cmd+scroll stays pan; plain scroll stays pane content. A debounced onViewportSettled re-anchors portals since synthesized magnification never fires didEndLiveMagnify. Verified shared machinery via canvas zoom out (renders all panes at smaller scale, no ghosts). Raw pinch/scroll input is not socket-drivable; those input paths are hand-verify. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * canvas: plain-drag tab tear-out/move for split-layout parity Dragging a tab out of a pane required holding Option, and a plain drag on the tab moved the whole pane — inverted from split layout, where dragging a tab manipulates the tab. Aziz: tabs must drag out into their own pane and move between panes with full parity. Now a plain drag that begins on a tab (not its close glyph) of a multi-tab pane tears that tab into its own pane and keeps dragging it; dropping on another pane's tab bar joins it there (existing join-on-drop path). Single- tab panes and drags on the empty title-bar area still move the whole pane. The tear-out/join model ops are unchanged — only the input trigger moved from Option+mouseDown to plain drag-activation in CanvasPaneView, so a plain click still selects the tab. Tab drag is gesture-only (not socket-drivable) = hand-verify; the underlying tear-out/join were already verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * canvas: don't leak internal TabManager class name in socket API error greptile P1: the canvas.* API returned message "TabManager not available", exposing an internal cmux class name to cmux canvas CLI callers, violating the user-facing error rule that API error bodies describe failures in product terms. Changed to "No active cmux window", matching the sibling canvas error messages ("Workspace not found", "Canvas pane not found"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * canvas: show Cmd+scroll discovery hint at top of canvas, not bottom Aziz preference: the one-time pill teaching Cmd+scroll-pans reads better anchored near the top of the canvas. Switch the pill constraint from bottomAnchor (-24) to topAnchor (+24). Scale+fade animation is non-directional so top placement needs no other change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * canvas: first-tab tear-out fix, Cmd+drag pane move, drag drop indicator Three tab-drag improvements from Aziz dogfood (implemented by a subagent, reviewed + Task A socket-verified here): 1. First/founding tab can now tear out. CanvasModel.breakOutPanel minted the new pane id from the panel UUID; a single-tab pane's id equals its founding panel's UUID, so for the founding tab that id collided with the source pane and layout.breakOutPanel's !contains guard rejected it — the first tab could never tear out. Mint a fresh UUID for the torn-out pane instead. Verified via canvas.break on a founding panel (45->46 panes, tab tears out). Adds a CanvasLayout founding-panel break-out unit test. 2. Cmd+drag a tab moves the whole pane (skips tear-out), mirroring Cmd+scroll targeting the canvas instead of pane content. Guarantees a move handle even when the tab bar overflows and has no empty title-bar region. 3. Drop indicator during a drag: CanvasGuidesView draws an accent highlight on the target pane's tab bar when the drag would join there, computed from the same predicate that commits the join on drop. Clears over empty canvas and on drag end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * canvas: add set-viewport + new-pane CLI/socket verbs, report viewport in info Closes the two CLI-coverage gaps Aziz flagged. Both flow through the typed canvas.* seam (context protocol -> coordinator -> CLI), 1:1 with the socket. - canvas.set_viewport / `cmux canvas set-viewport --x --y [--zoom]`: centers the viewport on a canvas point and optionally sets magnification (explicit clip-origin math, mirrors setMagnification). Makes Cmd+scroll pan and pinch/option-scroll zoom scriptable. - canvas.info now reports `magnification` and `viewport_center` (omitted in splits), so zoom/center are inspectable — and pan/zoom become socket-testable instead of eyeball-only. - canvas.new_pane / `cmux canvas new-pane [--type terminal|browser]`: opens a standalone canvas pane via Workspace.openNewCanvasPane (creates the surface, does NOT join as a tab, placer positions it, focus+reveal), returns the new surface ref. New .created(mode:surfaceID:) resolution case. Socket-verified: info shows magnification/center; set-viewport --x 600 --y 400 --zoom 0.5 reads back 0.5 @ (599.86,399.78); new-pane terminal+browser bump the pane count and return refs; invalid type rejected. 155 control-socket tests pass (+8). Adds coordinator tests + context stubs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 33e5380 commit b405287

117 files changed

Lines changed: 11690 additions & 50 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/swift-file-length-budget.tsv

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,52 @@
11
# cmux-owned Swift file length budget.
22
# Format: max_lines<TAB>relative path
33
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
4-
33285 CLI/cmux.swift
5-
19197 Sources/ContentView.swift
6-
17899 Sources/AppDelegate.swift
7-
14610 Sources/TerminalController.swift
8-
13572 Sources/Panels/BrowserPanel.swift
9-
12078 Sources/GhosttyTerminalView.swift
4+
33454 CLI/cmux.swift
5+
20034 Sources/Workspace.swift
6+
19272 Sources/ContentView.swift
7+
18130 Sources/AppDelegate.swift
8+
16680 Sources/GhosttyTerminalView.swift
9+
14774 Sources/TerminalController.swift
10+
13611 Sources/Panels/BrowserPanel.swift
1011
12046 cmuxTests/AppDelegateShortcutRoutingTests.swift
11-
11971 Sources/Workspace.swift
12+
10020 Sources/TabManager.swift
1213
9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
13-
7737 Sources/Panels/BrowserPanelView.swift
14-
7311 cmuxTests/WorkspaceUnitTests.swift
15-
6944 cmuxTests/WorkspaceRemoteConnectionTests.swift
16-
6316 cmuxTests/SessionPersistenceTests.swift
17-
6296 cmuxTests/GhosttyConfigTests.swift
14+
7850 Sources/Panels/BrowserPanelView.swift
15+
7349 cmuxTests/WorkspaceUnitTests.swift
16+
6948 cmuxTests/WorkspaceRemoteConnectionTests.swift
17+
6555 cmuxTests/GhosttyConfigTests.swift
18+
6332 cmuxTests/SessionPersistenceTests.swift
19+
6299 cmuxTests/TerminalAndGhosttyTests.swift
1820
6153 CLI/cmux_open.swift
19-
6074 Sources/TabManager.swift
2021
6074 Sources/TextBoxInput.swift
21-
5969 cmuxTests/TerminalAndGhosttyTests.swift
2222
5500 cmuxTests/BrowserConfigTests.swift
23-
5470 Sources/cmuxApp.swift
23+
5487 Sources/cmuxApp.swift
2424
4938 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
2525
4460 Sources/Panels/FilePreviewPanel.swift
2626
4400 cmuxTests/BrowserPanelTests.swift
2727
4227 Sources/BrowserWindowPortal.swift
28+
4009 cmuxTests/WindowAndDragTests.swift
2829
3937 Sources/Feed/FeedPanelView.swift
29-
3895 cmuxTests/WindowAndDragTests.swift
3030
3764 cmuxTests/TabManagerUnitTests.swift
3131
3699 cmuxTests/CLIGenericHookPersistenceTests.swift
32-
3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
32+
3665 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3333
3397 Sources/CmuxConfig.swift
3434
3331 cmuxTests/TabManagerSessionSnapshotTests.swift
3535
3202 Sources/Update/UpdateTitlebarAccessory.swift
3636
2877 Sources/SessionIndexView.swift
3737
2871 cmuxTests/CMUXOpenCommandTests.swift
38+
2623 Sources/TerminalNotificationStore.swift
39+
2573 Sources/KeyboardShortcutSettings.swift
3840
2565 Sources/Panels/CmuxWebView.swift
3941
2545 cmuxTests/WorkspaceManualUnreadTests.swift
4042
2544 cmuxTests/CommandPaletteSearchEngineTests.swift
41-
2516 Sources/KeyboardShortcutSettings.swift
4243
2377 Sources/Mobile/MobileHostService.swift
4344
2328 cmuxTests/CJKIMEInputTests.swift
4445
2314 Sources/FileExplorerView.swift
4546
2261 Sources/TerminalWindowPortal.swift
46-
2236 Sources/TerminalNotificationStore.swift
47+
2221 Sources/SessionPersistence.swift
4748
2134 cmuxTests/ShortcutAndCommandPaletteTests.swift
4849
2117 cmuxTests/CmuxConfigTests.swift
49-
2037 Sources/SessionPersistence.swift
5050
2031 Sources/KeyboardShortcutSettingsFileStore.swift
5151
1949 Sources/Panels/BrowserWebAuthnSupport.swift
5252
1860 cmuxTests/NotificationAndMenuBarTests.swift
@@ -61,14 +61,14 @@
6161
1560 cmuxTests/TextBoxMentionCompletionTests.swift
6262
1498 cmuxTests/OmnibarAndToolsTests.swift
6363
1496 cmuxUITests/MultiWindowNotificationsUITests.swift
64-
1446 Sources/FileExplorerStore.swift
64+
1448 Sources/FileExplorerStore.swift
6565
1410 Sources/CommandPalette/CommandPaletteSearch.swift
6666
1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift
6767
1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift
6868
1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift
6969
1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift
7070
1362 Sources/CMUXInstalledExtensionSidebarHostView.swift
71-
1312 cmuxTests/MobileHostAuthorizationTests.swift
71+
1313 cmuxTests/MobileHostAuthorizationTests.swift
7272
1292 Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift
7373
1285 cmuxUITests/SidebarHelpMenuUITests.swift
7474
1270 cmuxTests/RestorableAgentSessionIndexTests.swift
@@ -81,24 +81,25 @@
8181
1126 cmuxTests/FileExplorerStoreTests.swift
8282
1120 cmuxTests/AgentHibernationTests.swift
8383
1107 Sources/AppDelegate+CmuxSSHURL.swift
84+
1096 Sources/GhosttyConfig.swift
8485
1093 cmuxUITests/BonsplitTabDragUITests.swift
8586
1021 cmuxUITests/TerminalCmdClickUITests.swift
8687
1006 cmuxTests/CmuxSSHURLRequestTests.swift
8788
1000 cmuxTests/CmuxTopSnapshotScopeTests.swift
8889
947 Sources/TerminalNotificationPolicy.swift
8990
945 Sources/SessionIndexRegisteredAgents.swift
91+
944 Sources/App/ShortcutRoutingSupport.swift
9092
941 Sources/App/TerminalDirectoryOpenSupport.swift
9193
937 Sources/TextBoxMentionIndexStore.swift
92-
934 Sources/App/ShortcutRoutingSupport.swift
9394
926 Sources/DockPanelView.swift
9495
919 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
9596
917 cmuxTests/WorkspaceGroupTests.swift
9697
905 Sources/CmuxSSHURLRequest.swift
9798
903 Sources/CommandPalette/CommandPaletteSettingsToggle.swift
98-
880 Sources/WorkspaceContentView.swift
99+
897 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
100+
892 Sources/WorkspaceContentView.swift
99101
868 Sources/Panels/BrowserScreenshotSnapshotter.swift
100102
866 Sources/Panels/TerminalPanel.swift
101-
856 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
102103
852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
103104
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
104105
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
@@ -110,12 +111,12 @@
110111
770 Sources/MainWindowFocusController.swift
111112
762 Packages/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift
112113
760 Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift
114+
757 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
113115
756 Sources/Panels/AgentSessionWebRendererCoordinator.swift
114-
752 Sources/TerminalController+ControlWorkspaceContext.swift
116+
753 Sources/TerminalController+ControlWorkspaceContext.swift
115117
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
116118
739 Sources/App/MenuBarExtraController.swift
117119
738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
118-
738 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
119120
716 Sources/TaskManagerSnapshot.swift
120121
715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
121122
715 Sources/AppleScriptSupport.swift
@@ -143,6 +144,7 @@
143144
648 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift
144145
640 cmuxTests/CommandPaletteNucleoFFITests.swift
145146
630 Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift
147+
627 Sources/WorkspaceRemoteConfiguration.swift
146148
621 cmuxTests/FinderFileDropRegressionTests.swift
147149
621 cmuxUITests/RightSidebarChromeHeightUITests.swift
148150
620 cmuxTests/TerminalNotificationQueueTests.swift

CLI/cmux.swift

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3936,6 +3936,14 @@ struct CMUXCLI {
39363936
windowOverride: windowId
39373937
)
39383938

3939+
case "canvas":
3940+
try runCanvasNamespace(
3941+
commandArgs: commandArgs,
3942+
client: client,
3943+
jsonOutput: jsonOutput,
3944+
idFormat: idFormat
3945+
)
3946+
39393947
case "workspace":
39403948
try runWorkspaceNamespace(
39413949
commandArgs: commandArgs,
@@ -7334,6 +7342,134 @@ struct CMUXCLI {
73347342
}
73357343
}
73367344

7345+
/// `cmux canvas <info|mode|set-frame|align|reveal|overview|set-viewport|new-pane|…>`
7346+
/// — workspace canvas-layout control over the v2 `canvas.*` methods.
7347+
private func runCanvasNamespace(
7348+
commandArgs: [String],
7349+
client: SocketClient,
7350+
jsonOutput: Bool,
7351+
idFormat: CLIIDFormat
7352+
) throws {
7353+
guard let sub = commandArgs.first?.lowercased() else {
7354+
throw CLIError(message: "canvas requires a subcommand. Try: info, mode, set-frame, align, reveal, overview, zoom, set-viewport, new-pane")
7355+
}
7356+
let rest = Array(commandArgs.dropFirst())
7357+
// Split flags ("--name value") from bare positionals so a flag's
7358+
// value is never mistaken for a positional argument.
7359+
var positionals: [String] = []
7360+
var index = 0
7361+
while index < rest.count {
7362+
let arg = rest[index]
7363+
if arg.hasPrefix("--") {
7364+
index += 2
7365+
} else {
7366+
positionals.append(arg)
7367+
index += 1
7368+
}
7369+
}
7370+
7371+
var params: [String: Any] = [:]
7372+
if let workspaceRaw = optionValue(rest, name: "--workspace"),
7373+
let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client) {
7374+
params["workspace_id"] = wsId
7375+
}
7376+
7377+
func surfaceParam(positional: String?, required: Bool) throws {
7378+
let raw = optionValue(rest, name: "--surface") ?? positional
7379+
if let raw, let surfaceId = try normalizeSurfaceHandle(raw, client: client) {
7380+
params["surface_id"] = surfaceId
7381+
} else if required {
7382+
throw CLIError(message: "canvas \(sub) requires a surface (positional or --surface <id|ref>)")
7383+
}
7384+
}
7385+
7386+
let method: String
7387+
switch sub {
7388+
case "info":
7389+
method = "canvas.info"
7390+
case "mode":
7391+
guard let mode = positionals.first?.lowercased(),
7392+
["canvas", "splits", "toggle"].contains(mode) else {
7393+
throw CLIError(message: "Usage: cmux canvas mode <canvas|splits|toggle>")
7394+
}
7395+
params["mode"] = mode
7396+
method = "canvas.set_mode"
7397+
case "set-frame":
7398+
try surfaceParam(positional: positionals.first, required: true)
7399+
for key in ["x", "y", "width", "height"] {
7400+
guard let raw = optionValue(rest, name: "--\(key)"), let value = Double(raw) else {
7401+
throw CLIError(message: "canvas set-frame requires numeric --x --y --width --height")
7402+
}
7403+
params[key] = value
7404+
}
7405+
method = "canvas.set_frame"
7406+
case "align":
7407+
guard let command = positionals.first?.lowercased() else {
7408+
throw CLIError(message: "Usage: cmux canvas align <tidy|align-left|align-right|align-top|align-bottom|equalize-widths|equalize-heights|distribute-horizontally|distribute-vertically>")
7409+
}
7410+
params["command"] = command
7411+
method = "canvas.align"
7412+
case "reveal":
7413+
try surfaceParam(positional: positionals.first, required: false)
7414+
method = "canvas.reveal"
7415+
case "overview":
7416+
method = "canvas.overview"
7417+
case "zoom":
7418+
guard let direction = positionals.first?.lowercased(),
7419+
["in", "out", "reset"].contains(direction) else {
7420+
throw CLIError(message: "Usage: cmux canvas zoom <in|out|reset>")
7421+
}
7422+
params["direction"] = direction
7423+
method = "canvas.zoom"
7424+
case "join":
7425+
try surfaceParam(positional: positionals.first, required: true)
7426+
guard let targetRaw = positionals.dropFirst().first ?? optionValue(rest, name: "--target"),
7427+
let targetId = try normalizeSurfaceHandle(targetRaw, client: client) else {
7428+
throw CLIError(message: "Usage: cmux canvas join <surface> <target-surface>")
7429+
}
7430+
params["target_surface_id"] = targetId
7431+
method = "canvas.join"
7432+
case "break":
7433+
try surfaceParam(positional: positionals.first, required: true)
7434+
method = "canvas.break"
7435+
case "select-tab":
7436+
try surfaceParam(positional: positionals.first, required: true)
7437+
method = "canvas.select_tab"
7438+
case "set-viewport":
7439+
for key in ["x", "y"] {
7440+
guard let raw = optionValue(rest, name: "--\(key)"), let value = Double(raw) else {
7441+
throw CLIError(message: "canvas set-viewport requires numeric --x --y")
7442+
}
7443+
params[key] = value
7444+
}
7445+
if let raw = optionValue(rest, name: "--zoom") {
7446+
guard let value = Double(raw) else {
7447+
throw CLIError(message: "canvas set-viewport --zoom must be numeric")
7448+
}
7449+
params["zoom"] = value
7450+
}
7451+
method = "canvas.set_viewport"
7452+
case "new-pane":
7453+
if let type = optionValue(rest, name: "--type")?.lowercased() {
7454+
guard ["terminal", "browser"].contains(type) else {
7455+
throw CLIError(message: "Usage: cmux canvas new-pane [--type terminal|browser]")
7456+
}
7457+
params["type"] = type
7458+
}
7459+
method = "canvas.new_pane"
7460+
default:
7461+
throw CLIError(message: "Unknown canvas subcommand: \(sub). Try: info, mode, set-frame, align, reveal, overview, zoom, join, break, select-tab, set-viewport, new-pane")
7462+
}
7463+
7464+
let payload = try client.sendV2(method: method, params: params)
7465+
printV2Payload(
7466+
payload,
7467+
jsonOutput: jsonOutput,
7468+
idFormat: idFormat,
7469+
fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace", "surface"])
7470+
)
7471+
}
7472+
73377473
/// `cmux window displays` — list connected displays (name + index).
73387474
private func runWindowDisplaysCommand(client: SocketClient, jsonOutput: Bool) throws {
73397475
let response = try client.sendV2(method: "window.displays")
@@ -13327,6 +13463,39 @@ struct CMUXCLI {
1332713463

1332813464
Print server capabilities as JSON.
1332913465
"""
13466+
case "canvas":
13467+
return """
13468+
Usage: cmux canvas <subcommand> [args] [--workspace <id|ref>]
13469+
13470+
Control a workspace's freeform canvas layout.
13471+
13472+
Subcommands:
13473+
info Print layout mode and pane frames (z-order)
13474+
mode <canvas|splits|toggle> Switch layout mode
13475+
set-frame <surface> --x <n> --y <n> --width <n> --height <n>
13476+
Place one pane at an explicit frame
13477+
align <command> tidy, align-left, align-right, align-top,
13478+
align-bottom, equalize-widths, equalize-heights,
13479+
distribute-horizontally, distribute-vertically
13480+
reveal [<surface>] Scroll a pane into view (default: focused)
13481+
overview Toggle fit-all overview zoom
13482+
zoom <in|out|reset> Step viewport magnification
13483+
set-viewport --x <n> --y <n> [--zoom <n>]
13484+
Center the viewport on a canvas point
13485+
(optionally set magnification)
13486+
new-pane [--type terminal|browser]
13487+
Create a new free-floating canvas pane
13488+
join <surface> <target> Move a surface into the pane hosting target (tab)
13489+
break <surface> Tear a surface out of its multi-tab pane
13490+
select-tab <surface> Select a surface as its pane's visible tab
13491+
13492+
Example:
13493+
cmux canvas mode canvas
13494+
cmux canvas set-frame surface:1 --x 0 --y 0 --width 800 --height 520
13495+
cmux canvas set-viewport --x 400 --y 260 --zoom 1.0
13496+
cmux canvas new-pane --type terminal
13497+
cmux canvas align tidy
13498+
"""
1333013499
case "events":
1333113500
return """
1333213501
Usage: cmux events [options]

Packages/CmuxCanvas/Package.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// swift-tools-version: 6.0
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "CmuxCanvas",
7+
platforms: [
8+
.macOS(.v14),
9+
.iOS(.v17),
10+
],
11+
products: [
12+
.library(
13+
name: "CmuxCanvas",
14+
targets: ["CmuxCanvas"]
15+
),
16+
],
17+
targets: [
18+
.target(
19+
name: "CmuxCanvas",
20+
swiftSettings: [
21+
.swiftLanguageMode(.v6),
22+
.enableUpcomingFeature("ExistentialAny"),
23+
.enableUpcomingFeature("InternalImportsByDefault"),
24+
]
25+
),
26+
.testTarget(
27+
name: "CmuxCanvasTests",
28+
dependencies: [
29+
"CmuxCanvas",
30+
]
31+
),
32+
]
33+
)

0 commit comments

Comments
 (0)