# Floating Panes: Design Scope: Native sessions first. tmux integration is a second driver on the same model. Primary files: PTYTab, SessionView, PTYSession, PseudoTerminal, MovePaneController, sources/tmux/ A floating pane is a terminal session that sits above a tab's tiled layout. It is freely positioned, resizable, z-ordered, and overlaps the panes beneath it. Floats are expected to be short-lived helpers, since they block the view of the tiled panes, but they are full sessions and must survive an app relaunch. This doc records the model, the product decisions made so far, the open questions, and an inventory of existing features that need work. It is based on a read-only audit of the code at commit dd65a6b9d and of the tmux source in `~/git/tmux` (which reports version `next-3.9` and contains the 3.8 changes). Nothing described here has been built or run. Line numbers are from that commit and will drift; method names are the stable reference. Claims about AppKit or tmux runtime behavior that were inferred from code are marked "unverified". ## 0. Summary The assumption being broken: > A tab's sessions are exactly the leaves of the split tree rooted at > `PTYTab.root_`, and those leaves tile the tab with no overlap, no gaps, and > no z-order. The main conclusions: - Build native floats first. tmux 3.8 floats then map onto the same model with the server, rather than the user, owning geometry. - Every tab, native or tmux, gets a container view above `root_`. Floats are siblings of `root_` in that container (section 3.5). - Each float is a wrapper view that holds the outline, shadow and resize handles, with a one-child `PTYSplitView` inside it. The split view keeps the many `(NSSplitView *)view.superview` casts in PTYTab valid. Users cannot split a float. - A float's invariant is its grid (columns by rows), not its pixel frame. - A native tab always has at least one tiled session. A tmux tab may have none (section 12.5). - The whole feature is released together. The phases in section 13 order the work; they are not separate releases. - Floats are in-window views. A live Metal pane is expected to composite correctly over another; a spike confirms it (section 4). - Two tmux fixes are independent of this feature and should ship first (section 12.2). ## 1. Decisions made **Scope and release** | Topic | Decision | | --- | --- | | Name | “Floating pane”. Reusing the adjective from “floating hotkey window” is fine. | | Order of work | Native first, tmux second. The feature is not worth doing for tmux alone. | | Release | Everything ships together. No intermediate state reaches users. | | Refactor safety | During the container refactor `-[PTYTab sessions]` is renamed so that every caller must choose between all sessions and tiled sessions at compile time. It is renamed back afterward. | **Model** | Topic | Decision | | --- | --- | | Container | Floats are in-window views. Two Metal panes are assumed to composite correctly. If they do not, Metal is turned off for floats. | | Metal pane cap | Floats count toward the six-session limit above which a tab stops using Metal. The limit is there for performance. | | Older builds | Saved arrangements need not stay readable by older builds. Downgrading is not supported. | | Splits inside a float | Not supported natively. Split commands are disabled when a native float is active. The container and schema leave room to add them later. | | Split aimed at a float | A split that reaches a native float from a caller that cannot be disabled goes to the tab's most recently active tiled pane instead. | | Tiled to float conversion | Originally no direct command. Later added: “Move Session to Floating Pane” (Session > Move Session, and the pane's context menu) lifts the active tiled pane into a float in its own tab, placed like a new float, as the reverse of “Dock Floating Pane”. Another tiled pane must stay behind, natively and in tmux (tmux itself allows a floats-only window). A layout node whose every pane was floated is fine (section 12.1). The context menu shows the item only where it applies. Hidden floats are shown, since the user asked to see this one. Focus follows one rule in both directions: the pane keeps focus if it had it, and otherwise takes it by the rule for a new pane (not with focus follows mouse unless the advanced setting says so). No other pane's activity counter changes, so the pane used most recently is still the one the user last worked in. Swapping a tiled pane with a float still exchanges their roles. | | Swap | A tiled pane can be swapped with a float, and two floats can be swapped. Each takes the other's place, frame and z-order. | | Session order | Tiled sessions come first and floats after, wherever sessions are numbered or indexed. | | Last tiled pane leaves | Native tabs only. If the last tiled pane leaves the tab by any route (its session ends, it is docked into another tab, dragged to the tab bar or to empty space, moved with a Move Session item, or buried), the front float is promoted to be the tab's tiled pane. In a tmux tab nothing is promoted and the tab becomes floats-only, as tmux does. Other floats stay floating. Closing the float implicitly is not an option, since it may be doing something important. Promotion resizes the float's grid to fill the tab. | | Undo after promotion | If closing the last tiled pane promotes a float and the close is then undone, the pane returns as a new tab. Not worth special handling. | **Creation, size and position** | Topic | Decision | | --- | --- | | Creation | The menu item “New Floating Pane with Current Profile” comes first. The other creation points (section 6.1) follow in step 9 of the plan. | | Initial position | A new float is centered in the tab. | | Initial size | 80% of the tab's size in each dimension. To be revisited after feedback; a setting may follow. | | Minimum float size | 2x2 cells plus a fixed size in points for the chrome. It exists only to prevent disaster, not to guarantee a usable float. | | Tab minimum size | Floats contribute to the tab's minimum size. Each contributes its minimum grid at its own font plus its chrome, not its current grid. A float with a larger font than the tiled panes can be what constrains it. | | Maximize | Natively, floats stay visible over a maximized pane. Maximize never hides a float, so the hide toggle is the only way a float is hidden. tmux tabs follow the server (section 12.4). | | Keyboard resize and move | The existing menu items that resize a split pane apply to a float. New menu items move a float. | | Window escape sequences | From a native float, CSI t treats the pane as the window: resize and raise or lower act on the float, reports describe the float, and move and miniaturize are ignored (section 6.9). | **Appearance** | Topic | Decision | | --- | --- | | Chrome | A float always has a title bar, a one-point outline and a shadow. Its corners are square. | | Translucent floats | A float whose session is translucent gets an `NSVisualEffectView` underlay with within-window blending, so it shows a blurred version of what is beneath it and stays legible. It does not show the desktop. | | Shared background image | A float draws its slice of the shared image like any pane. The chrome separates it from the layout. | | Dimming | Floats are dimmed like any split pane. With “dim inactive split panes” on, an inactive float is dimmed, and focusing a float dims the other panes in the tab. | | Accessibility | VoiceOver reads tiled panes first and floats after, matching session order. Covered panes are still announced, since their sessions are running. | | Covered cursor | A float may cover the active tiled pane's cursor. The user can hide the floats. | **Focus and z-order** | Topic | Decision | | --- | --- | | Raising | Only a click, “Bring to Front”, creating a float, and reveal change z-order. Focus follows mouse changes focus without restacking. Keyboard navigation changes focus without raising. | **Hide and show** | Topic | Decision | | --- | --- | | Hide and show | There is a toggle that hides all floats in a tab while their sessions keep running. | | Focus when floats are hidden | If the active session is a float when floats are hidden, focus goes to the next most recently used session in the tab that is still visible. | | Navigation to hidden floats | Next Pane, Previous Pane, cmd-opt-N and Select Next Floating Pane can land on a hidden float. Focus entering a float unhides the floats. Select Next Floating Pane skips a float tmux hides (while its window is zoomed), since activating it would not show it. | | Broadcast input to hidden floats | Same policy as a maximized tab, whose hidden panes still receive broadcast input today. Hidden floats receive it. | | Hidden floats indicator | Hidden floats are shown with the existing indicators feature (`iTermIndicatorsHelper`), like the maximized-pane indicator. | | Hidden float activity | The hidden-floats indicator is decorated the way a tab's icon is, with the dead, bell, new-output and idle states of the hidden floats. Nothing unhides. | | Hidden float ends | A hidden float whose session ends follows its profile. With close on end it disappears without unhiding anything. | **Docking and dragging** | Topic | Decision | | --- | --- | | Dock | A float can be docked into the tiled layout by menu item or by drag. | | Dock gesture | Dragging a float over a tab keeps it floating by default. Holding Control shows the split halves for docking. Control is not used by any existing drag-start gesture. | | Float to an existing tab | The tab bar gains spring-loaded tab selection when a pane drag hovers over a tab. | **Lifecycle** | Topic | Decision | | --- | --- | | Session end | A float follows its profile's end-of-session setting, like any pane. | | Relaunch | Floats come back as floats. Upgrading with Sparkle must not lose state, including running sessions in floats. | | Duplicate Tab | High fidelity: floats are duplicated with their geometry and z-order. | **tmux** | Topic | Decision | | --- | --- | | tmux idioms | In tmux tabs, follow tmux's behavior where it differs from native, for example Split on a float creates another float. | | tmux float position | A tmux float is positioned so its top-left cell lines up with the matching cell of the tiled pane it overlaps. | | tmux float chrome | Chrome goes in the one-cell border tmux reserves around a float. The normal title bar is used, and if it does not fit it is clipped. A title bar never covers its own float's content. A float tmux draws without a border has an outline over its content's edge and a title bar that is shown and hidden with a toggle, like the stoplight hotbox, and appears above the float. Floats at negative offsets are clipped as in tmux. | | tmux floats-only windows | Supported. A tmux tab may have no tiled panes. Native tabs keep the invariant through promotion. | | tmux popups | No popup-specific work. tmux has removed popups; `display-popup` is now an undocumented compatibility command that opens a modal floating pane. A modal float is treated as an ordinary float. | | tmux before 3.8 | No floats. “New Floating Pane” is disabled in tmux tabs on older servers. Native and tmux sessions cannot mix in a tab, so drags are unaffected. | ## 2. Open questions None at present. Items that still need a look, rather than a decision, are marked "unverified" where they appear. ## 3. Model ### 3.1 Container `PTYTab` gains an ordered collection of floats, back to front. Each float is a wrapper view containing a `PTYSplitView` with exactly one `SessionView` child. The wrappers are siblings of `root_` in the tab's container view (section 3.5), above it. The float's frame and z-order belong to the wrapper. The wrapper is needed because `NSSplitView` lays its subviews out to fill it, and PTYTab's `splitView:resizeSubviewsWithOldSize:` (PTYTab.m:6595) does the same. A split view has no room of its own for an outline, a shadow or resize handles. The wrapper owns those (section 4.3) and insets the split view by the outline width. Why a one-child split view rather than a bare `SessionView`: - The split, peer-swap, synthetic-swap and divider paths all cast `view.superview` to `NSSplitView` and then use generic calls (`replaceSubview:with:`, `adjustSubviews`). With a per-float split view those casts stay true, and Instant Replay, Filter and screenshot-mode swaps inherit the float's frame and z-order with no extra bookkeeping. - Of 392 methods in PTYTab.m, 68 reference `root_` and 39 are already parameterized on a node (`_recursiveSize:`, `_recursiveMinSize:`, `adjustSubviewsOf:`, the `splitView:` delegate methods, `_recursiveEncodeArrangementForView:`, `_recursiveRestoreSplitters:`). - The `root_` identity tests that must become "is a root" are few: `assert(parentSplit == root_)` in `splitVertically:newSession:before:targetSession:` (PTYTab.m:2499), three cases in `cleanupAfterRemove:` (2091, 2129, 2137), `_recursiveViewFrame:` (2162, which walks up superviews until it meets `root_` and would not terminate under another root), and the enumerators. Float records must be keyed on the float's wrapper or on `viewId`, never on the `SessionView` pointer. During a synthetic swap the live view is parked in `hiddenLiveViews_` with no superview. Known hazards for the floats in the container: - `setRoot:` (PTYTab.m:5212) replaces `flexibleView_`'s subviews and re-adds only the root. It runs on maximize, unmaximize and tmux layout rebuilds, and would drop the floats. - `iTermFlexibleView drawRect:` (iTermFlexibleView.m:48-52) punches a clear hole for every subview frame, so it would punch one for each float. - `unmaximizeTemporarilyAndActivate:` (PTYTab.m:1187) and `setActiveSessionPreservingMaximization:` replace `root_.subviews[0]` with the target session's view while maximized. With a float as the target they would move the float into `root_`. - Nothing on the tab content path sets `clipsToBounds`. Floats are clamped to the tab natively, so clipping matters mainly for tmux (section 12.4). ### 3.2 Invariants - A native tab always has at least one tiled session. A tmux tab may have none, because tmux allows a window with only floats (section 12.5). - A float holds exactly one session. - Floats sit above all tiled panes. Among floats, order is explicit. ### 3.3 Enumeration `-[PTYTab sessions]` (PTYTab.m:1668) walks `root_`, or returns `idMap_` values while maximized. Nearly every feature in the app reaches panes through it or through `-[PseudoTerminal allSessions]`. Decision: `sessions` and `sessionViews` include floats, and a new `tiledSessions` serves the callers that must not see them. Each existing call site needs to be classified. To make that exhaustive, rename `sessions` for the duration of the refactor so that every caller fails to compile until it picks one, then rename it back. There are 33 `sessions.count` sites outside PTYTab.m alone. Known callers that need `tiledSessions`: - Pane title visibility, which uses `sessions.count > 1` in six places (PTYTab.m:612-627, 4386, 4483, 5434, 5748; PseudoTerminal.m:8183). - "Last pane" logic: `closeSession:soft:` (PseudoTerminal.m:1813), `closeSessionWithConfirmation` (2755), `sessionRemoveSession:` (PTYTab.m:7541), `removeAndClearSession` (MovePaneController.m:173), `reallyDropTab:` (197), the tab-bar drop edge case (PseudoTerminal.m:8757), and "drop on self moves the window" (PTYTab.m:7437). - Tab size: `size`, `currentSize`, `variableTmuxSize` (PTYTab.m:4718) and `sessionsAtTop/Left/Right/Bottom` (1400). `minSize` measures the tiled tree and then adds a term for floats (section 5). - `apply_layout`: the resolver, environment and mutator (iTermLayoutResolver.swift:152-162, iTermLayoutEnvironment.swift:49-55, iTermLayoutMutator.swift:276-283). - `recreateTab:` (PseudoTerminal.m:11271), which requires every live session to appear in a saved arrangement. - `replaceViewHierarchyWithParseTree:` (PTYTab.m:5410), which terminates every session left in `sessions` after rebuilding the tiled tree. - Menu validation: `hasMultipleSessions` (PTYTab.m:3048) counts `root_` subviews while other items use `sessions.count`; they must agree. Callers that count sessions and still need a decision: - `moveSessionToTab:` validation (PseudoTerminal.m:14070) and select-pane validation (14192). - `restorableSessionForSession:` (PseudoTerminal.m:2507), which chooses between restoring a session and restoring a tab. - The inline progress bar (PseudoTerminal.m:9279). - `textViewEnclosingTabHasMultipleSessions` (PTYSession.m:21890), used by the context menu. - `textViewSplitPaneWidthIsLocked:` (PTYSession.m:22050). - The tmux deregister count (PTYSession.m:3970). - The pane-move split (MovePaneController.m:281). - `arrangeSplitPanesEvenly:` validation (iTermApplicationDelegate.m:569). While a tab is maximized, `sessions` returns only `idMap_` values. Floats stay outside `idMap_` and `savedArrangement_`, so both accessors need to add floats in the maximized branch as well. Broadcast input relies on this: its domain is `currentTab.sessions` (PseudoTerminal.m:16090), which is why hidden panes of a maximized tab receive broadcast input today. ### 3.4 Geometry drivers Enumeration, z-order, focus, chrome and clipping are shared. Geometry ownership differs: - **Native:** the user owns the frame. The grid is canonical and the frame is derived from it. - **tmux:** the server owns a cell rectangle. iTerm2 derives a frame from it and sends commands to change it. Build this seam first so tmux is a second driver, not a second implementation. ### 3.5 Tab container view Today a native tab's `root_` is the tab view item's view: `setRoot:` sets `tabView_ = newRoot` (PTYTab.m:5223-5227). Only tmux tabs interpose a view, `flexibleView_`, so that the root can be smaller than the tab. Floats need a common superview, so every tab gets the interposed container and `root_` is always a child of it. The places that assume otherwise: **Ownership of the tab view item's view** (PTYTab.m) - `setRoot:` (5212-5227). The native branch makes the root the tab's view. The tmux branch wipes all of `flexibleView_`'s subviews. - `enableFlexibleView` (3562-3574) and `notifyWindowChanged` (3577-3582), which create the container lazily and for tmux only. - `setTabViewItem:` (1311) and `replaceWithContentsOfTab:` (3515), which moves `root_` subviews between tabs. **`flexibleView_ != nil` used to mean "is a tmux tab"** These must test `isTmuxTab` once every tab has a container: - `copyWithZone:` (570), `updateFlexibleViewColors` (1247), `rootViewSize` (5270), `updatedTmuxLayoutRequiresAdjustment` (5614) and the comparisons of root and container size at 5619-5641. - The `hasFlexibleView:` parameter chain: PTYTab.h:143, PTYTab.m:3636 and 4473, PseudoTerminal.m:2848, 4403, 11335, 11378, 11402, 12244 and 14701. **Sizing that treats `root_`'s frame as the tab's frame** - `setSize:` (2817-2826). The native branch resizes `root_` directly and the tmux branch resizes `tabView_`. Native must size the container and have the root follow. - The autoresizing mask set at 5224 is max-margin only. That works today because `NSTabView` sizes the item's view itself. Inside a container a native root will not track it. - `currentSize` (2809), `recompact` (2980), `absoluteFrame` (727-732), `image:` (2898), and the reads at 4711, 4933, 5125 and 5703. **Root identity tests and superview walks** - The cases listed in section 3.1, plus the tests at 2061 and 2185. - `replaceWithContentsOfTab:` (3533), which skips `root_` when moving delegates. - `_ancestorsOfLockedSession` (6432-6440) and `textViewSplitPaneWidthIsLocked:` (PTYSession.m:22055-22057). Both stop on a nil superview, so they are safe, but from a float they never reach `root_`. **Coordinates relative to `root_`** - `rootRelativeOriginOfSession:` (1080), the `sessionsAtTop/Left/Right/Bottom` comparisons (1405-1439) and directional navigation (1492-1588). These still convert correctly for a float. - `sessionContainerView:` (7630) returns `root_` and anchors the shared background image (section 4.2). It should return the container. - In a tmux tab the root can be smaller than the container. Float frames are relative to the container, not the root. **Outside PTYTab** - The swipe handler reparents `realRootView` (PseudoTerminal.m:16456-16463 and 16509-16510). It works if `realRootView` returns the container. - iTermSplitTreeRebuilder.swift:146 sizes a new root from `realRootView.bounds`. - `imageFromSelectedTabView:tabViewItem:` (PseudoTerminal.m:8062) uses the tab item's view and will start to include floats, which is the desired result. - The window-arrangement encoder (PseudoTerminal.m:4689) and `beginDrag:` (MovePaneController.m:334) read `session.view.superview`. This list covers the references that depend on where `root_` sits in the view hierarchy. The 68 methods that reference `root_` were not all traced. ## 4. Rendering ### 4.1 Metal over Metal (confirmation spike) Two live Metal panes have never overlapped. Splits tile, and maximize gives Metal to the active session only (PTYTab.m:7321-7324). Pane chrome already composites above the `CAMetalLayer`, which is encouraging but not proof. Spike: put one opaque Metal `SessionView` over another and confirm both stay live and composite in view order. Also check, by eye, a translucent float with its blur underlay (section 4.2) over a live Metal pane during a float drag and during a resize. The blur is sampled by the window server and needs no code, but the per-resize bounce to the legacy renderer (below) could make the blurred region lag or pop. In-window views are the decision (section 1), and the spike is expected to pass. If it fails, Metal is turned off for floats. `updateUseMetal` is all-or-nothing per tab today, so that means either a tab uses the legacy renderer while it has a float, as a tab with a browser pane already does (`metalAllowed:` returns NotATerminal at PTYSession.m:9450), or Metal becomes a per-session choice within a tab, which is new work. Other Metal facts that apply either way: - `updateUseMetal` (PTYTab.m:7247-7327) requires every session to pass `metalAllowed:`. A float with a zero or oversized frame, or with no window yet, turns Metal off for the tab (PTYSession.m:9463, 9486, 9554). - Metal is disabled at six or more sessions (PTYTab.m:7271). Floats count. - A drag that shows drop targets disables Metal (PTYSession.m:9498). - `SessionView` bounces to legacy for a runloop turn on each size change (SessionView.m:1434). Quantizing float resizes to whole cells limits this. ### 4.2 Opacity and background images Nothing opaque sits behind a pane (SessionView.m:559-578). Without an underlay, a translucent float would show the tiled pane's glyphs and every lower float through it. **Blur underlay.** A float whose session is translucent gets an `NSVisualEffectView` in its wrapper, beneath the split view. - Blending is within-window, so the float shows a blurred version of the tiled panes, lower floats and shared image beneath it. Behind-window blending would punch through to the desktop instead. - It lives in the wrapper, not in `SessionView`, so it survives Instant Replay and Filter swaps. - It is installed only while the session is translucent, and updates when the profile's transparency changes or the window's “Use Transparency” toggle flips. - Its appearance is forced from the profile's background color (dark background, dark appearance). The material is otherwise tinted by the system appearance and would wash out a dark terminal in light mode. - Its state is always active, like the existing ones, so floats do not go flat when the window loses key. - Precedent for a within-window effect view over terminal content: the hover URL view (SessionView.m:79-88), the minimal composer and announcements. Consequences: - A translucent float no longer shows the desktop. - The profile's blur setting and blur radius do not apply to a float. The material's blur is fixed and is on even when the profile's blur is off. - Tiled panes keep the window-level blur of the desktop, so the two kinds of pane look slightly different. - “Reduce transparency” in System Settings makes the material opaque with no work on our side. - Transparency adds no new Metal rule. A translucent session already forces its tab to the legacy renderer unless text is monochrome (PTYSession.m:9528-9541). **Shared background image.** With per-pane background images off, one image sits behind everything (PseudoTerminal.m:16020-16033) and each session draws its own slice. The slice is computed from the pane's position, not from the split tree: the Metal path passes the pane's frame as a fraction of the window's content view plus a container rectangle (`textViewRelativeFrame` and `textViewContainerRect`, PTYSession.m:13426-13450) to `iTermBackgroundImageRenderer`, and the legacy path does the equivalent in `iTermBackgroundDrawingHelper`. A float is another rectangle and draws its slice the same way. What needs handling: - `sessionContainerView:` must return the tab container rather than `root_` (section 3.5), or a float outside a small tmux root computes a slice outside the image. - The legacy path redraws only when asked. A live move must invalidate the float at each step, or the image slides with the float and then snaps back. Metal recomputes the slice every frame. - A float with a blur underlay skips its own slice. Otherwise it draws a sharp slice over a blurred copy of the same image. - The shared image is the current session's (PseudoTerminal.m:15994-16000). Focusing a float whose profile has a different image swaps the image for the whole window, as it does for split panes today. Also: - Window blur is a window-level effect and does not blur the tiled pane behind a float. - `blur` (PTYTab.m:3081) and the flexible view color (1242) read the active session only. ### 4.3 Chrome **Title bar.** A float always shows a title bar, regardless of the pane-titles setting. It is the grab handle. The existing `SessionTitleView` is reused. It already has the close button, menu button, lock button, pane number, title dimming, double-click handling and hosting for a per-pane status bar, and it is inside `SessionView`, so the grid-to-frame arithmetic already accounts for it. Changes: - `_showTitle` is per `SessionView` (SessionView.m:139) but the tab sets it from `sessions.count > 1`. The rule becomes "is a float, or the tab has more than one tiled pane". It must also apply to the synthetic view during Instant Replay and Filter swaps. - `mouseDragged:` (SessionTitleView.m:377) starts the pasteboard drag at once. A float needs the live move first (section 7). - The menu button gains the float items from section 6.7. - Double-click maximizes the float. **Outline and shadow.** These belong to the wrapper (section 3.1). - A one-point border and a shadow, with an explicit shadow path equal to the wrapper's bounds so it stays cheap during live moves. - Corners are square. Rounded corners would require masking the Metal layer. - The border color is the focus cue: the accent color when the float is active and a neutral separator color otherwise. Overlapping panes then have a focus indicator that does not depend on the dimming setting. - `iTermActivePaneBorderView` stays off for floats. It is drawn for the active session only, inside the scroll view's frame, and rounds corners at window edges (SessionView.m:2508-2606). - A float pushed into a rounded window corner has a square corner there, and the window clips a few pixels of its border. Accepted. The alternative is to inset the clamp by the window's corner radius. **Resize handles.** An invisible band of about four points around the outline, inside the wrapper, with cursor rects. It overlaps the shadow, so it costs no visible space. Being outside the `SessionView` bounds keeps it clear of mouse-reporting apps, selection starting at the edge, the scroller, and browser panes that swallow mouse events. The terminal margins are not grab zones. Clicks in the band go to the float, not to the pane beneath. ## 5. Sizing Today geometry flows from the window to the split tree to each `SessionView` frame to rows and columns. `sessionSizeForViewSize:` (PTYTab.m:2983) derives the grid, and `-[SessionView compactFrame]` (SessionView.m:2354) is the exact inverse. Only the ioctl is throttled, to one per 0.2 seconds (WinSizeController.swift:179-246). Rules for floats: - **The grid is canonical.** The frame is `compactFrame` for the grid, which includes the title bar, plus the wrapper's outline inset. Only a user edge-drag goes from frame to grid, snapped to whole cells. Otherwise the many callers of `fitSessionToCurrentViewSize:` (font, margin, scrollbar, title and toolbar changes) would silently cost the float rows. - **Window and tab resize.** Keep the grid. Clamp the position and preserve edge anchoring, so a float touching the right or bottom edge stays there. Shrink the grid only when the tab is smaller than the float, and remember the desired size so it returns when there is room. Proportional scaling would resize the PTY on every drag step. `fitTabsToWindow` runs for every tab, so the clamp must run for background tabs too. - **Tab size.** Floats never contribute to `size` or `currentSize`. Otherwise they inflate window refits. - **Tab minimum size.** Floats do contribute to `minSize`. A float with a larger font than the tiled panes can be the constraint. Each float contributes its minimum grid at its own font plus its chrome, not its current grid, so a large float does not block shrinking the window. The shrink-and-remember rule above covers the range in between. `windowWillResize:toSize:` enforces `minSize` for every tab (PseudoTerminal.m:5951), so hidden floats and floats in background tabs count. - **Session-initiated resize.** `sessionInitiatedResize:` (PseudoTerminal.m:7220) locks the session and refits the window. It is reached from CSI 8 t, DECCOLM, the API `grid_size` setter, AppleScript, Instant Replay frames, the Companion app's resize, and the Increase/Decrease Height and Width actions. For a float it must set the grid, set the frame, and clamp, with no window change. The same profile gates apply, and it is allowed in full screen. The exception is the Increase/Decrease Height and Width actions, which are window commands: with a float active they resize the window through a tiled session (section 6.8). Section 6.9 lists the gates in this path that a float must bypass. - **Font size change.** Under “adjust window when changing font size”, grow the float to keep its grid, clamped to the tab. With the setting off, keep the frame. `reallyChangeSessionFontSize:` (PTYTab.m:2240) refits the window today. - **No overhang.** A native float is clamped inside the tab. Nothing native forces overhang, and the title bar must stay reachable. - **Minimum and maximum.** Minimum is 2x2 cells plus a fixed size in points for the chrome. That is not a usable float; it only prevents a degenerate frame. tmux allows 1x1 and its floats are shown at whatever size the server gives. Maximum is the tab's bounds. The split view constrain callbacks do not apply to floats. - **Initial size.** 80% of the tab's width and height, rounded down to whole cells of the float's font. Pass it explicitly to `setupSession:withSize:`. The profile's columns and rows are not used. - **Initial position.** Centered in the tab. - **Extra chrome.** `rightExtra` (100 points for adjacent timestamps, plus gutter panels) and the legacy scroller widen a `SessionView`. Natively the frame simply includes them. - **Maximize.** Floats stay visible over a maximized tiled pane. Creating or revealing a float does not unmaximize. Maximizing a float sets its frame to the tab bounds and remembers the old frame, without the `idMap_` root swap or an arrangement round trip. A maximized float moves behind the other floats, which stay visible above it, as tmux does when a float is zoomed (section 12.1). ## 6. Interaction ### 6.1 Creation First version: “New Floating Pane with Current Profile”. Places that encode where a new session goes, for step 9 of the plan: - `iTermOpenStyle` (SessionCreation/iTermOpenStyle.h) - New-instance open style (`stylePreference <= 3`, iTermApplicationDelegate.m:871-885) - Key actions 26-29 and their four synchronized lists - Pointer actions (`kNew*SplitWithProfilePointerAction`) - Profiles window (`HORIZONTAL_PANE`, `VERTICAL_PANE`) and the Toolbelt profiles tool - Context menu split items, including the URL split items - API `SplitPaneRequest`, AppleScript `split`, the orchestrator's `SpawnWindowChoice` - Browser split actions ### 6.2 Moving and resizing - A plain title-bar drag moves the float live, clamped to the tab. The grid is unchanged, so there is no PTY resize. - Mouse-down on a float raises it. This needs its own code: a click makes a pane first responder on mouse-up, and a cmd-click never does (PTYMouseHandler.m:577-585). If raising only followed activation, a float would rise on release, and a drag-selection in a background float would happen while it was still behind. - Edge and corner drags resize, snapped to cells, with a size readout. There is no size readout on divider drags today, only on window resize. - The existing menu items that resize a split pane (the four Move Divider actions) apply to a float and resize it by one cell. - New menu items move a float by keyboard (section 6.7). - “Lock Layout” blocks float move, resize and docking. Window “Lock Size” does not. ### 6.3 Focus and z-order - `setActiveSession:` (PTYTab.m:937) only makes the text view first responder. It stays that way: becoming active does not raise a float. A float is raised by a click (section 6.2), by “Bring to Front”, when it is created, and by reveal. Focus follows mouse and keyboard navigation change focus and leave the z-order alone, so the active float can be partly covered by another float. - In a tmux tab the server owns z-order and raises a float when it becomes the active pane (section 12.4). iTerm2 follows the server there. - `-[PTYSession reveal]` (PTYSession.m:16806) never raises anything and, when the tab is maximized, calls `setActiveSessionPreservingMaximization:`, which replaces `root_.subviews[0]` with the target view. Reveal must unhide floats if hidden, raise the target, and never move a float into `root_`. - When the floats are hidden while a float is active, activate the next most recently used session in the tab that is still visible. Sessions already carry a per-tab `activityCounter` (PTYTab.m:944), which gives the order. - A float may cover the active tiled pane's cursor. That is accepted; the user can hide the floats. - When the active float closes, activate the next float in z-order, else the last active tiled session. `nearestNeighborOfSession:` (PTYTab.m:2261) uses the superview's subview index and returns nil for a lone float. ### 6.4 Navigation and numbering - `orderedSessions` (PTYTab.m:1087) has two orderings. With `navigatePanesInReadingOrder` on it sorts by origin; floats then sort after the tiled panes, in z-order, because overlapping origins make reading order meaningless. With it off it sorts by `activityCounter`, and floats take part like any pane. - While maximized, the reading-order branch builds the list from `_orderedSessionIDs`, which comes from `idMap_` (PTYTab.m:1089). Floats are not in `idMap_`, so with a float active `sessionInDirection:` finds no index and Next Pane silently does nothing. The list must add floats. - Pane ordinals (cmd-opt-N, title bars, the Toolbelt status tool) follow that order. - Next Pane, Previous Pane, cmd-opt-N and Select Next Floating Pane include hidden floats. Landing on one unhides the floats. - Directional select and swap (PTYTab.m:1489-1604) use a strict non-overlap test. Floats are excluded as candidates. From a float they do nothing. - Menu validation for these items must count consistently (section 3.3). ### 6.5 Hide and show A per-tab toggle hides all of the tab's floats. Sessions keep running. - One rule covers unhiding: focus entering a float unhides. Reveal, keyboard navigation, creating a float, and dropping a float into the tab all do this. - Hiding while a float is active moves focus (section 6.3). - Hidden floats still receive broadcast input, as the hidden panes of a maximized tab do today (section 3.3). - Hidden floats still count toward the tab's minimum size (section 5) and toward close confirmations. - The hidden state is saved in arrangements (section 10.1). - A hidden float whose session ends follows its profile. With close on end it disappears and nothing unhides. - Output, a bell, or a session that ends and stays open never unhides. They decorate the indicator (below). **Indicator.** Hidden floats use the existing indicators feature, the icons drawn at the top right of a terminal by `iTermIndicatorsHelper`. The maximized-pane indicator (`kiTermIndicatorMaximized`) is the model. - Add an indicator identifier with an SF Symbol, an entry in `sequentialIndicatorIdentifiers` (iTermIndicatorsHelper.m:303) and help text (377). `configureIndicatorsHelperWithRightMargin:` (PTYTextView.m:2348) sets it from a new delegate query. The Metal renderer draws indicators from the same helper. - It shows in every tiled pane of a tab that has hidden floats. Indicators are per session, and there is no tab-level place to draw one. Showing it only in the active pane would make it jump around. - Indicators are images with no count. The hover help text can give the count, since `helpTextForIndicatorWithName:sessionID:` is computed per session. That string needs plural handling per `docs/localization.md`. - Indicators are not clickable. Showing the floats is done from the menu or its shortcut. - Toggling hide must redraw every tiled pane in the tab. - The indicator is decorated the way a tab's icon is. `updateIcon` (PTYTab.m:798) picks one state by priority: status dot, dead, bell, new output, idle. The indicator shows the highest-priority state among the hidden floats, using the same images and the same “show new output indicator” setting. The state clears when the floats are shown. - Indicators are single images today. The decoration needs either composed images or a small badge drawn by `iTermIndicatorsHelper`. - Indicators are drawn by `PTYTextView`. A tab whose tiled panes are all browser panes has nowhere to show it. - In a tmux tab the same indicator covers floats that the server hides under zoom. tmux has no count or flag for that, so iTerm2 derives it by comparing the unzoomed layout with the visible layout (section 12.4). ### 6.6 Split, close, dock - Split items are disabled when a native float is active. The validation path (`canSplitPaneVertically:withBookmark:`) is called from one place and tests the current tab's active session, not the target session, so many callers reach a split without it. Section 6.9 lists every entry point, which ones are refused and which are redirected to a tiled pane. - Split selection mode (`setSplitSelectionMode:excludingSession:move:`, PseudoTerminal.m:12350) puts an overlay on every session in `allSessions`. It drives the keyboard “Move Session to Split Pane” and swap flows. For a move, floats must not be offered as targets, and they must be hidden or faded while the mode is on so they do not cover real targets. For a swap, floats are valid targets and stay visible (section 6.9). The API's `iTermSessionPicker` uses the same overlays. - Closing a float closes only the float. The "last session closes the tab" checks must not fire. - “Dock Floating Pane” moves the float into the tiled layout. ### 6.7 Menu items New: “New Floating Pane with Current Profile”, “Dock Floating Pane”, “Show/Hide Floating Panes”, “Bring to Front”, “Send to Back”, and four items that move the active float up, down, left and right. Strings follow `docs/localization.md`. Moving a float to another tab or window uses the existing Move Session items (section 6.8). The float's title bar menu and the context menu are one menu (`menuAtCoord:`, iTermTextViewContextMenuHelper.m:382) and offer the float items too. That menu validates only pane and layout locks today and needs float-aware validation for Split, Move, Swap and Bury. ### 6.8 Main menu audit Every item in MainMenu.xib was considered. Items not listed act on the current session or on the app and work unchanged in a float. **Shell** | Item | Effect | | --- | --- | | Duplicate Tab, Duplicate Window | Floats are duplicated with geometry and z-order (section 1). | | Split Horizontally and Vertically, with and without the sheet | Disabled when a native float is active (section 6.6). Enabled on a float in a tmux tab, where the server creates another float. tmux tabs follow tmux's idioms. | | Close | Closes only the float. Blocked by Lock Layout, like any pane (`closeSessionWithConfirmation:`). | | Close All Panes in Tab, Close Terminal Window | Include floats, hidden ones too. Confirmation counts must include them. | | Undo Close | Section 10.4. | | Save Archive of Current Session | Uses the only-session arrangement, which omits floats (section 10.1). Unaffected. | | Exit Workgroup | Disabled for floats, which cannot enter a workgroup. | | Broadcast Input to All Panes in Current Tab | Includes floats, hidden ones too. | | Show Background Pattern Indicator | Stripes composite the way background images do: each pane draws its own, and a float's cover what is beneath it. No work expected. | | tmux > Pause Pane | Per pane. Works for a float once floats are tmux panes. | **Edit** | Item | Effect | | --- | --- | | Find Globally | Reveals its match, which unhides and raises (section 6.3). | | Filter, Clear Instant Replay | Synthetic swaps inside the float (section 9). | | Alert on Marks in Offscreen Sessions | "Offscreen" today means a pane hidden by maximize, or a window or tab that is not showing (`PTYSession.m:19278-19286`). The maximize test treats every non-active pane as offscreen, which is wrong for floats that stay visible over a maximized pane. Hidden floats must count as offscreen. | **View** | Item | Effect | | --- | --- | | Toggle Full Screen, Show Tabs in Fullscreen, Show Toolbelt | Change the tab's size. Floats are clamped (section 5). | | Use Transparency, Disable Transparency for Active Window | Install or remove each float's blur underlay (section 4.2). | | Zoom In on Selection, Zoom Out, Start Instant Replay | Synthetic swaps inside the float. | | Find Cursor | Draws in its own overlay window at the cursor's screen position. If a float covers the cursor, the floats are hidden temporarily while Find Cursor is showing and restored afterward. This is not the hide toggle: it does not move focus, change the indicator, or get saved. | | Show Timestamps | Widens the `SessionView`. The grid is canonical, so the float grows and is clamped. | | Show Composer | The minimal composer needs about 217 points and behaves as in a narrow split. | | Maximize Active Pane | Section 5. | | Make Text Bigger, Normal Size, Smaller | Section 5, font size change. | | Restore Text and Session Size | Also resets the grid, through `sessionInitiatedResize:`. For a float that sets the float's grid with no window change. | | Open Quickly | Reveal. | **Session** | Item | Effect | | --- | --- | | Edit Session | A profile change can alter font, transparency and title. The float's frame, underlay and chrome must follow. | | Open Autocomplete, Command History, Recent Directories, Paste History | Separate windows placed at the cursor. They ignore in-tab z-order (section 9). | | Move Session to Window, Move Session to Tab | Work for a float; it becomes a tiled pane at the destination. There are no separate float-specific items. Their validation counts sessions (`PseudoTerminal.m:14070`) and must count correctly with floats. Moving the last tiled pane promotes a float natively and leaves a floats-only tab in tmux. | | Move Session to Split Pane | For a float this is the keyboard way to dock: split selection mode picks the target pane. Floats must not be offered as targets (section 6.6). | | Make Screenshot | Captures the window, so it includes floats. | | Bury Session | Works for a float. Burying the last tiled pane promotes a float. Disabled for tmux floats (section 12.6). | | Buried Sessions | Disinter goes through `recreateTab:` (section 10.4). | **Window** | Item | Effect | | --- | --- | | Zoom, Window Style, Arrange Windows Horizontally | Change the window's size. Floats are clamped. | | Save and Restore Window Arrangement, Load Arrangement from File | Include floats (section 10). | | Change Profile in Arrangement | Rewrites sessions in a saved arrangement and must reach float sessions (section 10.2). | | Move Tab to New Window, Merge All Windows | The tab carries its floats. Clamp after the destination refits. | | Select Pane Above, Below, Left, Right | Floats are not candidates. From a float they do nothing and should be disabled (section 6.4). | | Next Pane, Previous Pane | Include floats, hidden ones too (section 6.4). | | Previous Peer, Next Peer | Disabled for floats. | | Move Divider Up, Down, Left, Right | Resize a float by one cell: right and left change the width, down and up change the height. `moveCurrentSessionDividerBy:horizontally:` (PTYTab.m:5848) casts the session view's superview and that view's superview to split views. For a float the second is the wrapper, so floats need their own branch. The titles say "divider"; consider changing them when a float is active. | | Lock Split Pane Width | Meaningless for a float. Disabled. | | Lock Size | Does not affect floats (section 6.2). | | Lock Layout | Blocks float move, resize and dock (section 6.2). It already blocks closing a pane and splitting, so it also blocks closing a float and creating one. | | Arrange Split Panes Evenly | Tiled panes only. | | Resize Window > Decrease and Increase Height and Width | These resize the window, never a float. Today they call `sessionInitiatedResize:` on the current session (PseudoTerminal.m:12470-12497), which under section 5 would resize an active float. With a float active they must act on a tiled session's grid instead. | | Cockpit | Lists a tab's sessions through `orderedSessions`, so floats appear. | ### 6.9 Other sources of activity An audit of the other menus, key binding actions, pointer actions, the Python API and client, `it2`, AppleScript, built-in functions, Open Quickly, triggers, escape sequences, session lifecycle, the AI features, the Companion app and the Toolbelt. Findings are grouped by cause. Anything not listed acts on its own session and is unaffected. **Splitting a float.** Splits enter through two methods in PseudoTerminal.m. - `asyncSplitVertically:...` (11541) and `splitVertically:before:profile:targetSession:...` (11630) are used by the menus, the context menu and browser context menu, key actions 28 and 29, the pointer split actions, the API and AppleScript. The only check is `canSplitPaneVertically:`, which tests the tab's active session, not the target. A refusal for a native float target belongs here, after the tmux branch, and must follow the existing contract (`ready(nil, NO)` then `completion(nil, NO)`) or a suspended AppleScript command never resumes. - `splitVertically:before:addingSession:targetSession:performSetup:` (11441) is called directly by `iTermSessionLauncher` (314, 430) with a session that already exists, and the launcher then launches a process in it unconditionally. A refusal here would leave a running session with no tab. The launcher is reached by the Dock-icon file drop, the split open styles, and the orchestrator's `start_session` with `window=current` (OrchestratorDispatcher.swift:3053). These callers cannot be refused; the target must be changed before the session is created. - Callers with no validation that split the current session: the Toolbelt Profiles tool (ToolProfiles.m:156, 173), the Profiles window (iTermProfilesWindowController.m:230, 283), URL splits (iTermController.m:2110-2121, from PTYTextView.m:8057 and the context menu), and the browser's cmd-opt-click (PseudoTerminal.m:15012). - Rule (decided): a split whose target is a native float is redirected to the tab's most recently active tiled pane. Menu items that name the float as the thing being split are disabled instead. **Swapping.** A tiled pane can be swapped with a float, and a float with a float. `swapSession:withSession:` (PTYTab.m:6041) exchanges frames and moves each view into the other's split view. With a split view per float, that already puts the tiled pane's view in the float's wrapper and the float's view in the tree. tmux's `swap-pane` behaves the same way (section 12.1). - Swap With Next and Previous Pane (key actions 67, 68) pick the partner from `orderedSessions` (PTYTab.m:1151), which includes floats. - The context menu's “Swap With Session…” uses split selection mode. For a swap, floats are valid targets, unlike for a move (section 6.6). - Swap Pane Left, Right, Above, Below (53-56) pick a geometric neighbor. Floats are not candidates and from a float they do nothing, as for directional selection. - Each pane takes the other's frame, so both grids change. The pane that becomes a float gets the float's z-order, title bar and outline, and its blur underlay is recomputed from its own transparency. The float record is keyed on the wrapper, so it does not change. - A swap is refused if it would make a workgroup member a float. - The tab still has a tiled session afterward, so promotion is not involved. **Session-initiated resize.** Beyond section 5: - `screenSetSize:` (PTYSession.m:16071) and `sessionInitiatedResize:` (PseudoTerminal.m:7228) both return early in full screen, and DECCOLM has the same gate (VT100ScreenMutableState+TerminalDelegate.m:1018). A float can be resized in full screen, so the float path must bypass them. - `safelySetSessionSize:` enforces at least 20 columns (PseudoTerminal.m:13641), which conflicts with the 2x2 float minimum. - `sessionInitiatedResize:` sets the tab's locked session. The float path does not need it. - A CSI 8 t parameter of 0 means the screen size, computed from the window and screen (PTYSession.m:16169). For a float it means the tab's bounds. - CSI 4 t sets a pixel size by calling `setFrameSize:` on the window directly (PTYSession.m:16277). It is not routed through `sessionInitiatedResize:`. - CSI 1, 2, 3, 5 and 6 t and SetWindowFrame move, miniaturize, raise or lower the window. - CSI 14 t and 19 t report the window's size and room to grow. They are already inexact for split panes; a float's true maximum is the tab. - What tmux does (`input_csi_dispatch_winops`, input.c:2122): it ignores every window operation that changes something (1 to 11, 13, 20, 21, 24), for every pane, floating or not. Its reports describe the pane alone: 14 t gives the pane's size in pixels, and 18 t and 19 t both give the pane's grid. So tmux treats the pane as the window for reporting and offers no model for resizing. In a tmux tab these sequences never reach iTerm2. - Decided for a native float, following "the pane is the window": - 8 t and 4 t resize the float. 4 t rounds to whole cells. - 5 t and 6 t bring the float to the front or send it to the back. - 14 t, 18 t and 19 t report the float's pixel size, its grid, and the grid that fits the tab. - 1 t, 2 t, 3 t and SetWindowFrame are ignored. Miniaturizing one float has no meaning, and their coordinates are screen coordinates. - The Companion app's resize is gated by `companionSessionCanResizeWindow` (PTYSession.m:16119), which refuses in full screen and for maximized window styles. Those gates are wrong for a float. - Key actions 49-52 (increase and decrease height and width) pass the receiving session, and apply modes fan them out to every session in a tab or window. The redirect to a tiled session (section 6.8) is per receiver. **Focus and raising.** - Focus follows mouse makes a pane first responder on mouse entry. It changes focus and never restacks floats, which is standard for focus follows mouse. A click raises. Keyboard actions change focus and do not raise. - After the floats are hidden no mouse-entered event fires for the pane now under the pointer, so focus goes to the most recently used session rather than the pane under the pointer (unverified). - `focusNewSplitPaneWithFocusFollowsMouse` (PseudoTerminal.m:11492) decides whether a new split takes focus under focus follows mouse. A new float needs the same rule. - A cmd-opt-shift drag in a pane's text goes straight to `beginDrag:` (PTYMouseHandler.m:212). In a float it starts the live move, like the title bar. - The title bar's Option requirement (`requireOptionToDragSplitPaneTitleBar`) returns before `beginDrag:`. The live move must start before that gate. - Pinch changes the font of the current session, not the pane under the pointer (PseudoTerminal.m:1758). That is existing behavior, more visible with floats. **Counting and ordering.** - AppleScript's `session N of tab` is positional in `sessions` (PTYTab+Scripting.m:26). Tiled sessions come first and floats after, so existing indexes keep their meaning. - `session.termid` and `ITERM_SESSION_ID` take the pane number from `sessions.count` (PTYTab.m:7494). With the same order, floats do not renumber tiled panes. - The Toolbelt status tool shows a pane shortcut when the tab has more than one session and uses the view's ordinal (ToolStatus.swift:997). - “Lock All Panes in Tab” and “Unlock All” iterate `sessions` (PTYTab.m:5648) and include floats, hidden ones too. Tab Color sets every session in `sessions`. - Apply modes for actions and snippets (PTYSession.m:12094) use `sessions`, `allSessions` and `broadcastSessions`, so they include hidden floats, in line with the broadcast decision. **API and scripting.** See section 11. **Lifecycle.** - Promotion and undo. Closing or burying the last tiled pane saves an arrangement in which float F is a float; promotion then makes F tiled. Undo or disinter goes through `recreateTab:`, finds the live layout no longer matches, and brings the session back as a new tab. Accepted: it is a narrow case and special handling would risk bugs. - A float whose tab or window is gone is revived as a tiled tab (iTermController.m:1704-1760). - `sessionShouldAutoClose:` (PTYTab.m:7612) defers close-on-end while a split divider drag is in progress. A float move or resize drag must count too. - The restart announcement after a session ends, Capture Output's announcement, RequestAttention's flash and the AI chat mention picker's highlight are all drawn inside the session view. In a hidden float they are invisible, and the hidden-floats indicator is the only signal. **Menus outside the main menu.** - The context menu and the title bar menu are the same menu (`menuAtCoord:`, iTermTextViewContextMenuHelper.m:382). The float items from section 6.7 go there. A float always has a title bar, so this is their main home. - Bury is validated by `textViewCanBury` (PTYSession.m:15157), which must return NO for a tmux float. - “Move Session to Window” on a tmux float calls `breakOutWindowPane:` (unverified what the server does). - The Dock menu, the Profiles menu, Services and the tab-group menus have no pane items. **Not audited.** The browser session's own key binding dispatch, and which view AppKit picks as the drop destination when text or files are dropped where a float overlaps a tiled pane. ## 7. Dragging Today a title-bar drag, or a cmd-opt-shift drag in the text view, starts an AppKit drag session (`MovePaneController beginDrag:`). The view stays put and a drag image follows the pointer. The outcomes are: onto a pane (split halves, `dropInSession:half:`), onto a tab bar (new tab, `unknownObjectWasDropped:`), or onto nothing (new window, `moveSessionToNewWindow:`). Behavior for a float: | Drag | Behavior | Needs | | --- | --- | --- | | Within its tab | Live move, clamped to the tab. | A tracking loop on the title bar. | | Leaving the tab area | Escalates to the existing drag session. | A handoff from the tracking loop to `beginDrag:`. | | Onto a tab bar, between tabs | New tab with the float as its tiled pane. | `removeSession:` handling a float. | | Onto a tab in the tab bar | Spring-loaded: that tab is selected after a delay. | New work in `PSMTabBarControl`; it has no spring-loading. | | Onto nothing | New window sized from the float's grid. | `removeSession:` handling a float. | | Onto another tab's or window's content | Stays a float at the drop point, clamped. The grid shrinks only if the destination is smaller. | A new tab-level drop target. | | Control held over a pane | Split halves appear and the float docks there. | Reads live modifier state; nothing reads it today. | Rules and complications: - Holding Control during a live move escalates to the drag session at once, so the float can be docked into its own tab. - Floats are never split targets. `SessionView` registers every pane as a drop target (SessionView.m:210), so floats opt out. A drop that lands on a float counts as a drop on its tab. - Other floats can cover the pane you want to dock into. Fade or hide them while Control is held. - Session-count checks must count tiled panes only (section 3.3). - Dragging out the last tiled pane, to another tab, a tab bar or nothing, promotes the front float in the source tab. The tab-bar drop path special-cases `tab.sessions.count == 1` (PseudoTerminal.m:8757) and must count tiled sessions. In a tmux tab nothing is promoted (section 12.5). - A tab dragged onto a pane merges as a split and is refused when the tab has more than one session (PTYTab.m:7410). A tab with floats must also be refused. - A tab moved between windows carries its floats. Clamp them after the destination window refits. - Entering a maximized tab mid-drag unmaximizes it (`sessionDraggingEntered:`), which runs `setRoot:`. - `requireOptionToDragSplitPaneTitleBar` applies to the escalated drag, not to the live move. - Layout lock and session lock gate the live move as they gate `beginDrag:`. - Every drag has a menu equivalent (sections 6.7 and 6.8), for keyboard users and the API. Order of work: live move plus drag-out to a tab bar, a new window and a split reuse existing code and come first. The floating drop into another tab or window and spring-loaded tabs follow. ## 8. Mouse and hit testing - Click-to-activate works with default AppKit hit testing if the front float is the topmost subview. - Each `SessionView` installs tracking areas over its bounds with `NSTrackingActiveAlways` (SessionView.m:1537-1562). They are not occlusion-aware (unverified AppKit behavior). The expected failures: a tiled pane under a float also gets `mouseEntered`, so focus-follows-mouse can steal focus; moving from a float back onto the pane it sits inside produces no `mouseEntered`; and `mouseMoved` reaches the covered pane, driving URL underlines, cursor changes and mouse-motion reporting. The fix is a window-level `hitTest` gate like the one at SessionView.m:1609. - NSSplitView divider cursor rects under a float may still change the cursor (unverified). - Mouse-report coordinates, scroll wheel routing and selection autoscroll are view-local and follow hit testing. ## 9. Existing features | Feature | Required behavior or problem | | --- | --- | | Global Search, Open Quickly, notification click, AI chat, Cockpit, `FocusBuiltInFunction`, git status component | All call `reveal`. It must unhide and raise (section 6.3). Global Search also moves its panel away from the match rect and assumes the rect is visible. | | Notification click-to-reveal | `sessionWithViewId:` (PTYTab.m:1073) walks `root_` only and misses floats. | | Status bar | With per-pane status bars, a float has its own. With the shared window bar, the bar belongs to `currentSession` (PseudoTerminal.m:13010) and swaps when a float takes focus, taking any open search or filter component with it. If the float and the tiled pane differ on showing a bar, the tab height changes. | | Workgroups | No entry path checks the session's kind. Entry is refused for floats in `enterDisposition` (iTermWorkgroupController.swift:179). Peer swap and split children are not needed. A swap that would make a workgroup member a float is refused (section 6.9). | | Instant Replay, Filter, zoom on selection, screenshot mode | `replaceActiveSessionWithSyntheticSession:` (PTYTab.m:1772) works inside a float's split view. Replay saves and restores the window frame; a float needs the same for its own frame. Refuse dock and resize during replay. | | Broadcast input | Domains use `currentTab.sessions` or `allSessions`, so floats are included, hidden ones too. The background stripes composite like background images and need no work. | | Dimming | `setDimmingForSession:` (PseudoTerminal.m:12529) dims every non-active session. Floats are treated like any pane: with “dim inactive split panes” on, an inactive float is dimmed, and focusing a float dims the tab's other panes. The float's outline color shows focus either way. | | Accessibility | AppKit derives the accessibility tree from the view hierarchy, so floats appear without work. VoiceOver reads tiled panes first and floats after, and still announces covered panes. Check the traversal order with VoiceOver on. | | Tab title, color, icon, activity | They follow the active session, so they follow an active float. Acceptable natively. | | Bury | Works per session natively. Disinter goes through `recreateTab:` (section 10.4). | | Undo close | Section 10.4. | | Tab drag image | `image:` (PTYTab.m:2893) walks `root_` and omits floats. | | Size readout during window resize | Shows the current session's grid. With a float active it will not change. | | Window resize snapping | `windowWillResize:toSize:` (PseudoTerminal.m:5868) uses the active session's cell size and chrome. It should use a tiled session. | | Small-float overlays | The find bar (minimum 150 points), announcements, the minimal composer (minimum 217 points) and the hover URL view assume a reasonably large pane. They behave as in a narrow split today. | | Child and separate windows | The find indicator, autocomplete and history popups, and completions are windows placed at the cursor. They ignore in-tab z-order and can appear for a cursor that is covered. | | Per-session toolbar | Not needed for floats while workgroups are refused. | | Session variables | Add `session.isFloating`. `session.termid` pane numbers come from `sessions`; with tiled sessions ordered first, floats do not renumber tiled panes. | | Triggers | None create or move panes. Show Alert, Open Password Manager and Post Notification end in `reveal` or in the `sessionWithViewId:` lookup above. The password manager types into the window's current session, so `reveal` must make a float the active session, not only raise it. | | Unaffected | Secure Keyboard Entry, Composer, Restart Session, tab groups, swipe between tabs, SSH integration, hotkey windows, Services, and Toolbelt tools other than Profiles and Status (section 6.9). The Companion app lists floats as ordinary panes; its resize gate is in section 6.9. | Not audited in depth: portholes, annotations, multi-cursor, copy mode, the badge and the cursor guide. ## 10. Persistence and restoration ### 10.1 Schema Floats are an additive sibling of the tree, never a new node type inside it. Old builds ignore an unknown top-level key. ``` "Root": { ... unchanged; at least one session in a native tab ... } "Floating Panes Hidden": bool "Floating Panes": [ // back to front { "frame": {x, y, width, height}, // points, relative to the tab container "Container Size": {width, height}, "Desired Grid Size": {columns, rows}, // only if shrunk to fit the tab "Saved Frame": {x, y, width, height}, // only while the float is maximized "Node": { ... same shape as a node in the tiled tree ... } } ] ``` - The active session is not recorded on the float. Session nodes already carry `Is Active` (PTYTab.m:3242), and `Node` contains one. A second flag would be a second source of truth. - `Desired Grid Size` backs the shrink-and-remember rule in section 5. - `Node` holds a session node today. Using the tree's node shape leaves room for splits inside a float without a schema change. - On restore, scale the origin by the ratio of the new container to the saved one, take the size from the session's saved columns and rows at the current font, then clamp. Split frames are saved in points and act as proportions because the split view rescales them; floats have no such scaling. - Omit the key when `PTYTabArrangementOptionsOnlySessionID` is set (Duplicate Session and the archive path). ### 10.2 Walkers These PTYTab class methods take only `arrangement[Root]` and need a floats pass. One enumeration helper should replace the pattern: - `registerSessionsInArrangement` (330) - `arrangement:passesTest:` (3704) - `modifiedArrangement` (3709) - the four `repairedArrangement` variants (3718-3756) - `arrangementForSessionWithGUID` (3758) - `sessionMapWithArrangement` (4123) - `drawArrangementPreview` (3494) - `openPartialAttachmentsForArrangement` (3624) `openPartialAttachmentsForArrangement` matters most. Unextended, a float's process is not reattached; the float relaunches fresh and its old process is adopted as a tab by `iTermOrphanServerAdopter`. ### 10.3 Trust gate `RiskAnalyzer` in iTermArrangementTrustGate.swift (96-99, 177-190) walks Tabs, Root, Subviews, Session. A quarantined arrangement could hide commands, initial text and triggers in float sessions and be reported as zero risk. Extend it in the same commit as the decoder. ### 10.4 Undo close and bury Closing a pane stores the whole tab's arrangement (PTYTab.m:922-931). `recreateTab:` builds a GUID map from the tree, requires every live session in the tab to be in it, and otherwise re-adds the revived session as a new tab. On success it calls `replaceWithContentsOfTab:` (PTYTab.m:3515), which moves only `root_` subviews. - `recreateTab:` should rebuild and check only the tiled tree and leave live floats alone. - Undo-closing a float re-adds it from its saved frame with no rebuild. - Bury and disinter use the same path (iTermBuriedSessions.m:66-75, 144). ### 10.5 Other paths - **Maximize.** `maximize` snapshots the tab and `unmaximize` rebuilds from the snapshot. Floats stay out of `idMap_` and `savedArrangement_` and are encoded live in both branches of the encoder (PTYTab.m:3969-3997). - **Duplicate Tab.** `arrangementForDuplication` then `tabWithArrangement:`. It works once the encoder and decoder handle floats. - **Graph encoder.** Key floats by session GUID in an `encodeArrayWithKey:` array. Duplicate identifiers trip an assert (iTermGraphEncoder.m:221-223). Order gives z-order. - **Saved window arrangements and hotkey-window arrangements** use the same encoder, so they include floats. - **Older builds** are not a constraint. One reading a newer arrangement loses the floats, and may fail to restore a window that contains a floats-only tmux tab. - **Settings sanitizer.** Redaction is by key name at any depth, so session dictionaries under the new key are covered. Avoid new sub-keys named `value`, `text`, `name`, `title`, `command` or `url`. ## 11. API and scripting - **Proto.** Add `repeated FloatingPane floating_panes = 12` on `Tab`, with a `SplitTreeNode` (or session summary), frame and z-index. Never add a `oneof` case to `SplitTreeNode`: the Python client's `Splitter.from_node` treats any non-session link as a splitter (session.py:42-58). - **Do not reuse `minimized_sessions`.** The client marks those sessions buried with no grid size. This happens by itself unless prevented: `minimizedSessions` (PTYTab.m:7490) is `sessions` minus the active session, so once `sessions` includes floats, every float in a maximized tab is reported as minimized while it is still visible. It must use `tiledSessions`. - **Consistency.** Today the session list comes from `rootSplitTreeNode` while subscriptions, variable scopes, activate, close and split look sessions up through `allSessions`. Floats would produce notifications for sessions the client's tree has never seen. Old clients trigger an extra refresh and otherwise ignore them. - **Notifications.** A float resize that changes the grid already fires LayoutChanged, through `PTYSessionDidResizeNotification` (iTermAPIHelper.m:950). Only window live resize and split divider drags are suppressed, so a float edge-drag would send a full ListSessions payload per grid step; it needs the same suppression. Float move, restack, hide and show, and a dock that keeps the grid fire nothing. Add a notification or reuse the layout-changed one. - **Requests.** New: create float, dock, set frame, raise. A split of a native float is refused. `SetTabLayout` checks tree shape only and is unaffected in native tabs. - **Python client.** Add `Tab.floating_sessions`; include floats in `all_sessions` and `get_session_by_id`; keep `Tab.sessions` tiled-only. `Tab.current_session` (tab.py:189) searches `sessions` and returns `None` when the active session is a float, in any tab; it must search all sessions. `Tab.sessions` can be empty for a floats-only tmux tab. - **`apply_layout`** must see tiled sessions only, or existing scripts fail with `orphanedSession` on any tab that has a float. - **AppleScript.** `sessions of tab` is `[self sessions]` and includes floats automatically, after the tiled sessions. There is no command to create, dock, move or raise a float; add them or leave AppleScript without. - **Built-in functions.** `select_pane_in_direction` excludes floats. `move_session_to_new_tab` and `move_session_to_new_window` work for a float through `MovePaneController`. Further items from the audit of every endpoint: - `active_session_id` and the Focus response name a float that is not in `root`. - `SplitPane` with `session == "all"` iterates `allSessions`. One refused float sets `CannotSplit` for the whole response although the tiled splits succeeded (iTermAPIHelper.m:2675). - `Activate` with `select_session` on a maximized tab does unmaximize, set active, maximize (iTermAPIHelper.m:3130). With a float target the last step would maximize the float. - `GetBroadcastDomains` lists float IDs that a client cannot resolve from the tree. - In the Python client, `Tab.all_sessions` (tab.py:165) is what `get_window_and_tab_for_session`, `Session.tab`, `Session.window` and the layout-change merge go through. Floats must be in it, or `Session.tab` is `None` for a float. `Tab.update_session` and `Tab.update_from` must carry floats too. - `Session.__init__` (session.py:250) has two modes, a tree link with frame and grid or a summary marked buried. Floats need a third, with a frame and grid. - An old client that gets a focus change onto a float does one extra ListSessions and Focus round trip (app.py:423). - `it2` resolves a missing session argument to the first leaf of the selected tab's tree, not to `active_session_id` (APIClient.swift:53). With a float focused every such command targets a tiled pane, and on a floats-only tab it fails. It should use the active session. `broadcast add` rejects a float ID because it validates against tree leaves (AppCommand.swift:404). - `move_session` (MoveSessionBuiltInFunction.swift:45) accepts a float as the destination, which would split a float and must be refused, and as the source, which is a dock and is allowed. `apply_layout` resolves a float GUID as a leaf reference; it should be an error. ## 12. tmux integration ### 12.1 Protocol facts (verified in tmux source) - Opt in with `refresh-client -f new-layouts`, control clients only. Layouts then arrive as JSON: `{"V":2,"L":cell}`. There are no spaces and no checksum. - A float is a leaf with a `"z"` key, mixed in with tiled siblings. The root can be a floating leaf, and nodes can contain only floats. - Layout x, y, w and h are the inner content rectangle. `move-pane -X/-Y`, `resize-pane -x/-y` and `new-pane` take outer coordinates when the float has a border. Sending layout values back unchanged drifts by one or two cells. - Layout `w` includes reserved scrollbar columns. `pane_width` is the content width. - `pane_z` is non-empty for tiled panes, so it cannot identify a float. `pane_floating_flag` cannot either while the window is zoomed: it tests the pane's layout cell (`window_pane_is_floating`, window.c:2969), and a float hidden by zoom has no cell, so it reports 0. Use the `"z"` key in the unzoomed `window_layout`, which still lists hidden floats. - While a modal pane exists, tmux adds `O` to the window flags sent with `%layout-change` (window.c:1395, undocumented). `#{window_modal_pane}` gives the pane ID. - After `break-pane -W` on the last tiled child of a node, tmux gives the node's space to its neighbour and leaves the node in the tree at 0x0 (`layout_remove_tile`, layout.c:1965), so the JSON contains a node with `"w":0` holding only floats. Later layout passes skip nodes with no tiled child, and tiling a float back into such a node re-tiles the node first (`layout_insert_tile`). iTerm2's parser drops a node with no tiled child and reads sizes only from the root, so the layout loads. tmux's own parser rejects `"w":0`, but iTerm2 never sends a JSON layout back. - These changes send no `%layout-change`: raising a float by focusing it (only `%window-pane-changed`), mouse move and resize by another client, and `rotate-window`. A `refresh-client -B` subscription on `#{window_layout}`, which always uses the JSON format, is required to stay in sync. - `display-popup` and the buffer and customize editors are modal floating panes with pane IDs and `%output`. A control client's own `display-popup` is a silent no-op, but one opened by another client is visible. - While a pane is modal, `select-pane` to another pane silently does nothing, and swap, join and break of the modal pane fail with "pane is modal". `send-keys` to other panes is not blocked (unverified by running). - `split-window` on a float creates another float. `swap-pane` exchanges cells, so a tiled pane can become floating. - `break-pane -W` makes a tiled pane floating. `join-pane` with a floating source and no destination, or the source as its own destination, tiles it (`cmd_join_pane_tile`, cmd-join-pane.c:386). Both fail while the window is zoomed, and tiling fails with "no space for a new pane" when the layout is full. - Zoom (`window_zoom`, window.c:986) hides every float except those created with `-A` or as a modal with `-O`, which carry `PANE_FLOATOVERZOOM`. Those keep their geometry, and a move or resize made while zoomed is copied back on unzoom. A hidden float loses its layout cell until unzoom. - A float can itself be zoomed. It fills the window and moves behind the floats in z-order, so over-zoom floats still show above it. It returns to the floating part of the z-order on unzoom. - Zooming does not take the active pane away from an over-zoom float. A modal or `-A` float can be the active pane over a zoomed pane. - Creating an `-A` or `-O` float in a zoomed window restores the zoom afterward (`restore_zoom`, cmd-split-window.c:146). Other commands that need the full layout unzoom first through `window_push_zoom`. - On window resize tmux shrinks oversize floats and fixes right and bottom overflow only. Negative offsets persist. Clamping is per window, so a smaller client moves floats for everyone. - A v1 layout string is still accepted by `select-layout` after opting in and leaves floats alone. Keep `iTermTmuxLayoutBuilder` on v1. A JSON layout would need quoting and would also apply active-pane and z-order state. - Default bindings create floats: `*` runs `new-pane`, and Tab and BTab open process-less floats running a mode. - Border lines, border styles, window style and popup title are per-pane options readable through `iTermTmuxOptionMonitor`. - Test fixtures: `regress/layout-custom.sh` has 55 JSON layouts, including nested float-only nodes, reordered keys and rejects. ### 12.2 Fixes independent of this feature - **Layout parse failure aborts the connection.** A client that has not opted in gets `0000,` for a window with only floats. `parsedLayoutFromString:` returns nil and TmuxWindowOpener.m (107, 159, 568) calls `abortWithErrorMessage:`. This affects shipping builds against tmux 3.8. - **Commands sent without error tolerance abort the connection** (TmuxGateway.m:636-643). With floats and modals these can now fail: `swap-pane` (TmuxController.m:3311), `break-pane` (2731), `select-layout` (3354, "no panes" on a floats-only window), `resize-pane -L/R/U/D` (2323), `new-window` (2520) and the `list-panes` that starts a split (2371). ### 12.3 Opting in Feature detection is better than a version gate. tmux 3.2 and later silently skips unknown `-f` flags, and older versions fail on a command iTerm2 already sends tolerantly. Confirm with `#{client_flags}` or a layout beginning with `{`. The version parser accepts `next-3.9`, `3.8`, `3.8a` and `3.8-rc3` as at least 3.8, the last by accident, and never accepts OpenBSD version strings. Versions are also compared as decimal numbers (`versionAtLeastDecimalNumberWithString:`), so a future tmux 3.10 would compare as 3.1 and fail a 3.8 gate. Opting in cannot ship before the UI exists, because floats would then appear in layouts that nothing can display. ### 12.4 Mapping onto the model - **Parser.** A new JSON parser emits the existing dictionary shape for tiled cells plus a separate float list, collapsing nodes left with one child. `depthFirstSearchParseTree:` callers in `openWindows:` and `decorateParseTree:` must see floats too, or floats get no history, state, user variables or tab-color decoration. - **Zero-size cells.** The parser must accept a node with a zero width or height rather than abort the connection (sections 12.1 and 12.2). - **Dropped output.** `didLearnLayout:forWindow:` (TmuxController.m:959) feeds `TmuxDroppedOutputScanner.didLearnPanes` from `windowPanesInParseTree:`. It must include float pane IDs, or a float's `%output` counts as dropped even after opting in. - **Conversion.** `replaceViewHierarchyWithParseTree:` terminates sessions missing from the new tiled tree. A pane toggled between tiled and floating by the server must be moved, not terminated and recreated. - **Cell-to-pixel mapping.** Each tiled pane adds margins, a scroller and optional title bar, and a divider is one point where tmux uses a cell (`_sessionSizeWithCellSize:`, PTYTab.m:2553). The pixel x of tmux column X drifts with the number of dividers to its left. So there is no global mapping. The rule is local: - Find the tiled pane that contains the float's top-left content cell in tmux coordinates. Place the float's top-left cell exactly over that cell of that pane. - The float moves in pixels when the tiled layout changes, because the pane it is anchored to moved. That is the correct result. - If the cell is not inside a tiled pane (a divider cell, a negative offset, or past the last pane), anchor to the nearest tiled pane and offset by whole cells. - In a floats-only window there is no tiled pane. Use a uniform grid from the container's origin. - **Chrome.** The content rectangle is fixed by the server, so chrome cannot take space from it, and it cannot always go outside it either: a float can be as large as the pane or the window it covers. tmux has the same problem and solves it with the border. The layout gives a float's content rectangle, say column 10, row 5, 40 wide and 12 tall. If the float has a border, tmux draws it in the cells just outside that: the float occupies columns 9 to 50 and rows 4 to 17 on screen. That larger rectangle, the content plus a one-cell border on every side, is what the move and resize commands address (section 12.1). iTerm2 puts its chrome in that border. - **Bordered float.** The wrapper covers the content plus the border. The title bar goes in the border's top row, and the outline, resize band and session margins go in the sides and bottom. Content cells stay aligned, and a float as large as the window has its border inside the window. - **Title bar height.** The normal `SessionTitleView` is used, at its normal height, which is the status bar height plus one point (SessionView.m:53). That is taller than a cell at common font sizes. The excess extends upward, away from the content, and is clipped at the top of the tab. A title bar never covers its own float's content; being clipped is preferred. No compact variant is built. - **Borderless float** (`pane-border-lines` is none). tmux reserves no border and draws no chrome. iTerm2 draws a one-point outline over the edge of the content and hides the title bar. A toggle shows and hides it, following the stoplight hotbox, which slides in and out behind a narrow toggle button when it would cover a toolbar or status bar (`_stoplightHotboxToggle`, iTermRootTerminalView.m:175). While shown, the title bar sits above the content, outside the float, and covers whatever is there. It is clipped at the top of the tab, so for a borderless float in the top row it cannot be shown. - **Negative offsets.** tmux leaves floats partly outside the window, so iTerm2 must too. The wrapper is clipped at the tab's edge, and a float whose title bar is outside is reachable by keyboard and menu until it is moved. - The session's side margins plus the outline must fit in one cell width. The default margin does. A larger margin setting makes the chrome extend past the border and cover a little more than tmux would. - **Border status.** Floats reserve no row for `pane-border-status`. `adjustLeavesInNode:` (TmuxLayoutParser.m:148) would wrongly shrink a float at y = 0. - **Client size.** `variableTmuxSize` filters `sessionViews` by geometry and would count floats as stacked tiled panes. Floats must not contribute. - **Raise.** `%window-pane-changed` to a float must bring that float to the front, since the server raises a float when it becomes active. - **Move and resize.** Send `move-pane` and `resize-pane` with the outer coordinate conversion. `_isDraggingSplitInTmuxTab` forces legacy rendering during divider drags; a float drag needs an equivalent. - **Split from a float.** tmux creates another float. iTerm2's split bookkeeping (`willSplitTmuxPane`, the senior and junior logic) assumes a tree change, and a popup appearing first would steal focus meant for the new pane. - **Zoom.** `maximize` assumes the zoomed pane is the active session. With a modal or `-A` float active it would maximize the float. - **Floats under zoom.** Native tabs keep every float visible over a maximized pane. In a tmux tab the server decides: while zoomed, show only the floats in the visible layout, which are the over-zoom ones. The others are hidden by the server, not by the hide toggle, and return on unzoom. They still exist, so they must not be terminated when they drop out of the visible layout (see Conversion above), and they are identified from the unzoomed `window_layout` (section 12.1). - **Title bars.** A float appearing in a single-pane tab must not turn on the tiled pane's title bar and resize the client. The rule in section 4.3 counts tiled panes only. - **Dragging.** Within-tab moves map to `move-pane -X -Y`. Dragging out maps to `break-pane`, which makes the pane tiled in a new window. - **Dock.** “Dock Floating Pane” maps to `join-pane` on the float, which tiles it (section 12.1). The server chooses where it goes. It is refused while the window is zoomed. - **Hazard.** `setTmuxLayout:` paths run `setRoot:` (section 3.1). ### 12.5 Floats-only windows Killing the last tiled pane keeps the window, and tmux then shows only floats. iTerm2 supports this: a tmux tab may have an empty tiled tree. Native tabs never do, because promotion (section 1) keeps one tiled session. iTerm2 does not promote in a tmux tab. It could, by sending `join-pane` to tile the front float, but tmux itself leaves the window floats-only, another client attached to the session would see a pane change state for no reason it can observe, and the command fails while zoomed. The same holds when the last tiled pane is moved out with `break-pane`. The container refactor (section 3.5) makes this representable: `root_` is a child of the container and can be a split view with no subviews, with the floats beside it. The work, roughly in order of difficulty: - **Client size.** `tmuxSizeForHeight:` (PTYTab.m:4589) measures the tiled tree and returns 0 when it is empty. In fixed-size mode `fitLayoutToWindows` (TmuxController.m:1094) then returns early, which freezes resizing for every window in the session. An empty tree must report a size derived from the container, as the code at PTYTab.m:7559 already does from the tab's frame. `variableTmuxSize` needs the same treatment. - **Tab size.** `size` and `currentSize` come from `root_`, which is empty, and floats do not contribute. The tab must keep the size the server gave for the window, or the window refit collapses it. - **Transitions.** `replaceViewHierarchyWithParseTree:` and the `setTmuxLayout:` paths must handle a tree becoming empty (last tiled pane killed) and becoming non-empty again (a float is tiled). Maximize and `root_.subviews[0]` uses must tolerate an empty root. - **Callers of `tiledSessions`.** Any that take the first element or assume a count of at least one need a fallback, for example window resize snapping (section 9). The rename in section 3.3 forces a look at each; "may be empty in a tmux tab" is one of the things to check. - **Parsing.** A window with only floats has a root that is a floating leaf or a node of floats. Without opting in it is `0000,` (section 12.2). - **Close.** Closing the last pane of such a window sends `%window-close` with no preceding `%layout-change`. - **Background.** The empty tiled area shows the container's background. A floats-only tab still has sessions, because floats are sessions. `sessions` and `activeSession` stay non-empty, so code that assumes a tab has a session is unaffected. What becomes empty is the tiled tree, which narrows the risk to two groups: - Code that indexes into `root_`. In PTYTab.m there are eleven such sites. Most are guarded already: the maximize paths (1134, 1198, 5784) run only while maximized, the checks at 2070 and 2089 test for a count of one first, and 2276 handles an empty split view because `replaceViewHierarchyWithParseTree:` already empties the tree transiently. The ones to fix are `replaceWithContentsOfTab:` (3544, 3548) and the split assert at 2492. - Callers moved to `tiledSessions` during the refactor. Each can now see an empty list in a tmux tab. The tree is already empty for a moment today, between the last session being removed and the tab closing, so the recursive walkers tolerate it. What is new is staying empty. A test that builds a tmux tab with an empty tree and floats, then calls each public PTYTab method, should be part of this work. **Code outside PTYTab.m that reads the tree.** An audit of the callers of PTYTab's tree, size and layout methods, and of the tmux parse-tree consumers. Each item is something an empty tiled tree breaks or that must learn about floats. Sizes, where a zero from one tab spreads: - `tmuxCompatibleSize` (PseudoTerminal.m:3978) takes the minimum of every tmux tab's `tmuxSize`. One floats-only tab reporting 0 makes the whole window's size 0. It feeds `setClientSize:` (PseudoTerminal.m:5266, TmuxController.m:1036). - `tmuxSize` is also read at TmuxController.m:1058, 1069, 1090 and 1113 and sent to the server at PseudoTerminal.m:7669. The guard at 1094 hides the zero by doing nothing, which is the freeze described above. - `fitWindowToTab:` (PseudoTerminal.m:10638) fits the window to `[tab size]`. The window sizing helper reads `currentSize` and `minSize` (PseudoTerminal.m:11803, 11812), as do `minWidth` (12237) and `windowWillResize:toSize:` (5953). Those take a maximum, so a zero is harmless there, but they must see the float contribution to `minSize`. Tab construction and layout: - `openTabWithArrangement:` (PseudoTerminal.m:12259) returns nil when `sessionViews` is empty. It stays correct only if `sessionViews` includes floats. - `parsedLayoutFromString:` (TmuxLayoutParser.m:96-120) already returns nil for the empty v1 layout, with a comment about tmux 3.8, and wraps a lone leaf in a root splitter. The JSON parser must produce a root splitter with no children for a floats-only window. - `TmuxWindowOpener` builds its per-pane requests with `depthFirstSearchParseTree:` (TmuxWindowOpener.m:112, 663) and its pane list with `windowPanesInParseTree:` (170, 607). Both must cover floats, or floats get no history and no state. - `positionForWindowWithPanes:windowID:` (TmuxController.m:2915) restores a window's position by looking up its pane IDs. A floats-only window has no tiled pane IDs, so floats must be in the list. - `didLearnLayout:forWindow:` (TmuxController.m:959), already listed in section 12.4. API and scripting: - `rootSplitTreeNode` (iTermAPIHelper.m:2403) sends a root with no links for a floats-only tab. The Python client builds an empty `Splitter`, so `Tab.sessions` is an empty list and scripts that index it raise. - `apply_layout` already refuses tmux tabs (iTermLayoutResolver.swift:124), so the empty tree never reaches it. - On a floats-only tab an empty `SetTabLayout` request passes the tree-shape check and sends a size and a layout built from an empty node (PTYTab.m:5104). What the layout builder produces for no children is unverified. Not a problem, checked: - `tab.sessions.firstObject.profile` (TmuxController.m:3889) and `[[tab sessions] objectAtIndex:0]` (MovePaneController.m:204, PseudoTerminal.m:4326) read `sessions`, which includes floats. - cmd-opt-N (iTermApplication.m:465), the Cockpit and the AI chat mention picker read `orderedSessions`, which includes floats. - `arrangeSplitPanesEvenly` and the split tree rebuilder act on the tiled tree and do nothing useful, but nothing harmful, when it is empty. None of this is architectural once the container exists. The client-size and tab-size items are the risky ones, because a mistake there affects every window in the tmux session, not just the floats-only one. ### 12.6 Popups, modals and process-less panes Decision: no popup-specific work. tmux removed popups and their overlay machinery (commit 34cd5da4); `display-popup` remains as an undocumented compatibility command that opens a modal floating pane. A modal float is treated as an ordinary float, and the error tolerance from section 12.2 keeps its failures from aborting the connection. The items below record what that leaves imperfect. They are not planned work. - **Races.** `display-popup -E true` opens and closes within milliseconds. The opener's queued commands fail, and iTerm2 reports "A tmux pane terminated immediately after creation" and drops the window's affinity (TmuxController.m:3947). - **Tab identity.** `setActiveSession:` updates the tab and window title, the `tab.currentSession` variable and focus history, and closes Instant Replay on the previous pane. A popup becoming active makes the tab title flicker unless `set-titles` is on. - **Profile side effects.** Popups use the shared tmux profile, so triggers, automatic profile switching, and bell and idle alerts apply. A font change from profile switching re-fonts every pane and refits the window. - **Prompts.** A popup counts toward "confirm closing multiple sessions" and prompts on close under "prompt if jobs". - **Modal detection.** iTerm2 learns that a window has a modal pane from the `O` window flag in `%layout-change`, and which pane from `#{window_modal_pane}` (section 12.1). `parseLayoutChangeCommand:` (TmuxGateway.m) reads only `Z` from the flags today. - **Modal enforcement.** All input goes by pane ID and bypasses tmux's modal. iTerm2 must enforce it, or snap focus back. `setCurrentWindow` (TmuxController.m:4039) increments an ignore counter before `select-window`; if tmux refuses during a modal, the next real notification may be swallowed (unverified). - **Dismissal.** A popup without `-E` leaves a dead pane waiting for a key. That dismissal is in tmux's client-key path, which `send-keys` bypasses. iTerm2 gets no notification that a pane died. Close with `kill-pane` or `display-popup -C`. - **Process-less panes.** `new-pane -E` panes never send `%output`, and modes draw to a mode screen that `capture-pane` reads only with `-M`. They would render blank while keys still drive the mode. iTerm2 ignores `%pane-mode-changed` today, so tiled panes in copy mode already have this problem. - **Monitors.** Each tmux session installs title, job and status-line monitors. Each popup adds pollers against a pane that may already be gone. - **Bury.** `bury` on a tmux pane hides the whole tmux window. Disable it for floats. ### 12.7 Multiple clients - A second control client that has not opted in gets v1 layouts without floats. It still receives `%output` for float pane IDs, which iTerm2 treats as dropped output. - Layout format is per client; z-order and the active pane are per window. ## 13. Phased plan Small, reviewable commits, each with tests. The steps order the work. The feature is released as a whole, so no step has to be shippable on its own except step 0. 0. **tmux fixes** from section 12.2. Independent; ship first. 1. **Metal spike** (throwaway). Confirms the in-window container. Includes the blur underlay check from section 4.1. 1a. **Test infrastructure.** Built before the feature so every later step can be checked without a person at the keyboard (section 14.1). 2. **Container refactor.** First, the tab container view for every tab (section 3.5), as its own commit with no floats. Then the float wrapper with its split view, `setRoot:` and `cleanupAfterRemove:` changes, `sessions` versus `tiledSessions` with the temporary rename, and the call-site classification. While classifying, mark each `tiledSessions` caller that cannot handle an empty list; those are fixed in step 10. Add tests for remove, maximize, enumeration and `replaceViewHierarchyWithParseTree:`; none exist today. 3. **Native float basics.** The menu items, sizing rules, title bar, outline, shadow, blur underlay, live move, edge resize, keyboard move and resize, raise on click, close, hide and show. 4. **Focus and mouse.** Occlusion-aware hit testing for focus-follows-mouse and hover, navigation order, numbering, maximize. 5. **Persistence.** Encoder, decoder, walkers, trust gate, partial attachment, undo close and bury, Duplicate Tab, with round-trip tests. 6. **Dragging.** Drag-out to tab bar, window and split with Control; then the floating drop target and spring-loaded tabs. 7. **Existing features.** Reveal, status bar, workgroup refusal, synthetic swaps, the rest of section 9, and from section 6.9 the split redirect, the swap rule, the resize gates and the focus rules. 8. **Automation.** Proto field, handlers, Python client, `it2`, AppleScript, built-in functions, and the consistency items in section 6.9. 9. **Remaining creation points** from section 6.1. 10. **tmux.** In this order: 1. The JSON parser, with floats reaching every parse-tree consumer (section 12.5 audit). 2. Opt-in and the server-owned geometry driver, with local cell alignment. 3. Floats under zoom. 4. Floats-only windows: tab and client size for an empty tree, then transitions, then the `tiledSessions` callers marked in step 2. The size work comes first because a zero from one tab reaches every window in the session. Release notes go in docs/notes-3.7.txt per docs/release-notes-guidelines.md. ## 14. Verification ### 14.1 Test infrastructure The feature is mostly about what the user sees and does with the mouse, and neither unit tests nor the Python API cover that. These come first: - **Window capture.** A debug-only way to capture the real, composited window to a PNG, Metal layers included, for example with ScreenCaptureKit. The Python API's screenshot renders one session's grid, not the window, and ordinary view snapshots omit Metal layers. The dev build needs Screen Recording permission once. - **Event harness.** A helper in the test bundle that hosts a real terminal window and delivers synthesized mouse events to its views: down, dragged, up, moved, with modifiers. It covers live move, edge resize, click to raise, the title bar's double-click and the handoff to a pane drag. - **Driving the dev instance.** The Python API can invoke menu items, send text and read layout and focus against the `-suite` instance. API connections from outside the app prompt for authorization, which must be pre-approved for the suite. - **GUI automation of the dev instance.** For this project the author has permitted controlling the computer and using accessibility APIs, which the repository's general rules forbid. Real clicks and drags against the `-suite` instance cover what the in-process harness cannot: AppKit drag sessions between tabs and windows, and how the window server delivers pointer events to covered panes. Synthetic input must only ever go to the dev instance, never to the main iTerm2 where the tools run. - **tmux.** tmux built from `~/git/tmux`, a plain second client run in a pty, and recorded control-mode transcripts fed to the parser and controller in unit tests. Two constraints on the implementation follow from this: - Float move and resize are written as mouse event handlers with explicit state, not as a modal `trackEventsMatchingMask:` loop, which a test cannot drive. This supersedes the "tracking loop" wording in section 7 and the appendix. - Geometry lives in plain types with no views: clamping, cell snapping, edge anchoring, shrink-and-remember, minimum size, tmux cell alignment and promotion. They are unit-tested exhaustively. What still needs a person: a final judgment on the Metal spike, since flicker and lag in motion are hard to judge from captures; VoiceOver; and how the feature feels. ### 14.2 Tests - **Unit tests.** A tmux tab with an empty tiled tree and two floats, with every public PTYTab method called on it; `tmuxSize`, `size` and `tmuxCompatibleSize` for a window that mixes a floats-only tab with a normal one; the tree going empty and non-empty through `replaceViewHierarchyWithParseTree:`. PTYTab container operations (step 2); arrangement round trips including old-format input; the trust gate with float sessions; the JSON layout parser against fixtures from `regress/layout-custom.sh` (tiled only matches v1, mixed, floats only, one tiled child plus floats, nested float-only nodes, negative offsets, malformed input). - **Manual, native.** Create, move, resize, hide, dock and close floats; relaunch and upgrade with floats running jobs; Duplicate Tab; window resize, full screen and font changes; every drag in section 7; maximize; Global Search into a covered or hidden float. - **Manual, tmux.** Build tmux from `~/git/tmux` and run `tmux -CC` with a second plain client: `new-pane` variants, `display-popup`, `break-pane -W` and `join-pane`, zoom with and without `-A`, modal `-O`, killing the last tiled pane, detach and reattach, and window resize. Add the recipe to tests/tmux_layout_smoke.md. - **Regression.** tmux 3.7 and older must still get v1 layouts. ## Appendix: reuse candidates, checked against the code | Need | Candidate | What it actually offers | | --- | --- | --- | | Move loop | Minimal composer (iTermMinimalComposerViewController.m:77-112) | A real `trackEventsMatchingMask:` loop, but vertical only and clamped to the superview. A pattern, not reusable code. | | Edge resize | Split divider drag (PTYSplitView.m:144) | It defers to NSSplitView, which owns the tracking loop and cursor rects. Only the cell quantization idea (PTYTab.m:6967) carries over. | | Grab handle | SessionTitleView.m:377 | `mouseDragged:` starts the pasteboard drag immediately. It needs a live-move mode. | | Dock and drag-out | MovePaneController | Suitable. It has no notion of geometry, so "float here" needs a new drop target. | | Title bar | SessionTitleView | Close, menu and lock buttons, pane number, dimming, double-click and per-pane status bar hosting. Reused as is, apart from the drag and the menu. | | Border | iTermActivePaneBorderView | Active session only, inside the scroll view. Not a drop-in. The wrapper draws the float's outline instead. | | Blur underlay | Hover URL view (SessionView.m:79-88) | A within-window `NSVisualEffectView` over terminal content. A pattern to copy. | | Shadow | ContentNavigationShortcut.swift:89-92 | Four layer shadow properties. Trivial to copy. | | Synthetic swap state | `replaceActiveSessionWithSyntheticSession:` | Works unchanged inside a per-float split view. | | Legacy-renderer fallback | Browser panes in `metalAllowed:` | Precedent for forcing a whole tab to the legacy renderer. |