From 1ced57b16b008496bbd0446a7a55e48d17659add Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 13:16:37 -0700 Subject: [PATCH 01/16] Add configurable iOS toolbar rows --- .../TerminalShortcutsSettingsView.swift | 118 +++++++-- .../GhosttySurfaceView.swift | 50 ++-- .../TerminalAccessoryConfiguration.swift | 150 +++++++++-- .../TerminalInputTextView.swift | 234 +++++++++++------- .../TerminalAccessoryLayoutReducer.swift | 212 ++++++++++++++-- .../TerminalAccessoryLayoutReducerTests.swift | 56 +++++ ios/cmux/Resources/Localizable.xcstrings | 85 +++++++ 7 files changed, 732 insertions(+), 173 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift index f9d29fcb8d1e..412c69839634 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift @@ -28,15 +28,56 @@ struct TerminalShortcutsSettingsView: View { var body: some View { NavigationStack { List { - Section { - ForEach(displayedItems) { item in - row(for: item) + if scope == .terminal { + Section { + Stepper( + value: rowCountBinding, + in: TerminalAccessoryConfiguration.minimumRowCount...TerminalAccessoryConfiguration.maximumRowCount + ) { + HStack { + Text(L10n.string("mobile.shortcuts.rows.label", defaultValue: "Rows")) + Spacer() + Text("\(configuration.rowCount)") + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("TerminalShortcutsRowCountStepper") + } header: { + Text(L10n.string("mobile.shortcuts.rows.header", defaultValue: "Toolbar Rows")) + } footer: { + Text(L10n.string( + "mobile.shortcuts.rows.footer", + defaultValue: "Add rows to keep more buttons visible above the keyboard." + )) + } + + ForEach(displayedItemRows.indices, id: \.self) { rowIndex in + Section { + ForEach(displayedItemRows[rowIndex]) { item in + row(for: item, rowIndex: rowIndex) + } + .onMove { offsets, destination in + moveDisplayedItems(from: offsets, to: destination, inRow: rowIndex) + } + } header: { + Text(rowTitle(rowIndex)) + } footer: { + if rowIndex == displayedItemRows.count - 1 { + Text(scope.footer) + } + } + } + } else { + Section { + ForEach(displayedItems) { item in + row(for: item, rowIndex: nil) + } + .onMove(perform: moveDisplayedItems) + } header: { + Text(L10n.string("mobile.shortcuts.header", defaultValue: "Shortcut Buttons")) + } footer: { + Text(scope.footer) } - .onMove(perform: moveDisplayedItems) - } header: { - Text(L10n.string("mobile.shortcuts.header", defaultValue: "Shortcut Buttons")) - } footer: { - Text(scope.footer) } Section { @@ -84,12 +125,28 @@ struct TerminalShortcutsSettingsView: View { } @ViewBuilder - private func row(for item: ResolvedToolbarItem) -> some View { - Toggle(isOn: binding(for: item.id)) { - if item.isCustom { - Label(item.settingsDisplayName, systemImage: "character.cursor.ibeam") - } else { - Text(item.settingsDisplayName) + private func row(for item: ResolvedToolbarItem, rowIndex: Int?) -> some View { + HStack { + Toggle(isOn: binding(for: item.id)) { + if item.isCustom { + Label(item.settingsDisplayName, systemImage: "character.cursor.ibeam") + } else { + Text(item.settingsDisplayName) + } + } + + if scope == .terminal, configuration.rowCount > 1 { + Picker( + L10n.string("mobile.shortcuts.rows.movePicker", defaultValue: "Move to Row"), + selection: rowBinding(for: item.id, fallback: rowIndex ?? 0) + ) { + ForEach(0.. { + Binding( + get: { configuration.rowCount }, + set: { configuration.setRowCount($0) } + ) + } + + private func rowBinding(for id: ToolbarItemID, fallback: Int) -> Binding { + Binding( + get: { rowIndex(for: id) ?? fallback }, + set: { configuration.moveItem(id, toRow: $0) } + ) + } + private var displayedItems: [ResolvedToolbarItem] { configuration.displayItems.filter(scope.includes) } + private var displayedItemRows: [[ResolvedToolbarItem]] { + configuration.displayItemRows.map { row in row.filter(scope.includes) } + } + + private func rowIndex(for id: ToolbarItemID) -> Int? { + configuration.displayRows.firstIndex { row in row.contains(id) } + } + + private func rowTitle(_ rowIndex: Int) -> String { + String( + format: L10n.string("mobile.shortcuts.rows.rowTitleFormat", defaultValue: "Row %d"), + rowIndex + 1 + ) + } + private func moveDisplayedItems(from offsets: IndexSet, to destination: Int) { guard scope != .terminal else { configuration.moveItems(from: offsets, to: destination) @@ -141,5 +227,9 @@ struct TerminalShortcutsSettingsView: View { } configuration.reorderItems(reorderedFullIDs) } + + private func moveDisplayedItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { + configuration.moveItems(from: offsets, to: destination, inRow: rowIndex) + } } #endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 57055e84696a..9639bd7a8cd6 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1062,6 +1062,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { name: UIResponder.keyboardWillChangeFrameNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAccessoryConfigurationChanged), + name: TerminalAccessoryConfiguration.didChangeNotification, + object: nil + ) } @objc private func handleAppWillResignActive() { @@ -1132,14 +1138,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// still lands even if the toolbar UI is absent. private var reservedToolbarHeight: CGFloat = 0 /// Height of the docked accessory bar reserved in the grid geometry so the - /// bottom TUI rows stay visible above it. Locked to the bar's actual button-row - /// height (`TerminalInputTextView.dockedButtonRowHeight`) so the grid reserves - /// EXACTLY the strip the buttons occupy — no taller. Round 3 reserved 44 while - /// the strip was only 34, so the extra 10pt rendered as bar background below - /// the buttons (the "gap below" Lawrence kept seeing). Matching them keeps the - /// toolbar's live top edge equal to the viewport edge; any whole-cell render - /// remainder stays inside the terminal viewport instead of becoming toolbar fill. - private static let persistentToolbarHeight: CGFloat = TerminalInputTextView.dockedButtonRowHeight + /// bottom TUI rows stay visible above it. Locked to the bar's actual configured + /// row-stack height so the grid reserves exactly the strip the buttons occupy. + private var persistentToolbarHeight: CGFloat { + TerminalInputTextView.dockedButtonRowHeight(rowCount: TerminalAccessoryConfiguration.shared.rowCount) + } /// The docked accessory bar. Positioned by ``bottomDockFrames()`` with the /// SAME bottom-occupancy math as the grid reservation, so its top is always /// flush with the grid bottom (no gap) and its bottom rests on the keyboard @@ -1477,7 +1480,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// height again. Idempotent: a no-op when already in the target state. private func updateDockedToolbarVisibility() { let shouldShow = dockedToolbarShouldBeVisible - let reserved: CGFloat = shouldShow ? Self.persistentToolbarHeight : 0 + let reserved: CGFloat = shouldShow ? persistentToolbarHeight : 0 guard dockedToolbar?.isHidden != !shouldShow || reservedToolbarHeight != reserved else { return } dockedToolbar?.isHidden = !shouldShow // The composer band rides with the toolbar: hide it when the chrome is @@ -1491,6 +1494,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { setNeedsLayout() } + @objc private func handleAccessoryConfigurationChanged() { + updateDockedToolbarVisibility() + layoutBottomDock() + layoutRenderedTerminalForCurrentViewport() + layoutZoomOverlay() + setNeedsGeometrySync() + setNeedsLayout() + } + /// Temporarily hide (or re-show) the bottom chrome — the always-visible toolbar /// and any open composer band — via the HIDE button (item 2). /// @@ -1816,12 +1828,13 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// the composer is now the chrome closest to the keyboard, with the always-visible /// toolbar above it. /// - /// The toolbar's button row is bottom-pinned inside its container (see - /// `TerminalInputTextView.dockedButtonRowHeight`), so the controls always hug the - /// band's bottom. The toolbar's TOP is the live terminal viewport bottom. The - /// rendered layer is independently bottom-pinned to that same viewport while - /// async libghostty resize catches up, so the keyboard transition has one moving - /// bottom edge instead of a dock edge derived from stale render readback. + /// The toolbar's row stack is bottom-pinned inside its container (see + /// `TerminalInputTextView.dockedButtonRowHeight(rowCount:)`), so the controls + /// always hug the band's bottom. The toolbar's TOP is the live terminal + /// viewport bottom. The rendered layer is independently bottom-pinned to that + /// same viewport while async libghostty resize catches up, so the keyboard + /// transition has one moving bottom edge instead of a dock edge derived from + /// stale render readback. /// /// While the HIDE button has suppressed the chrome (``chromeHidden``) the dock is /// off screen (both frames `.zero`); the grid reservation matches (it reserves 0), @@ -1837,7 +1850,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let width = bounds.width let effectiveComposerHeight = chromeHidden ? 0 : composerBandHeight // Composer band sits directly above the keyboard (or the safe-area inset), - // pinned to the bottom edge; the toolbar's button band reserves + // pinned to the bottom edge; the toolbar's row stack reserves // `persistentToolbarHeight` directly above the composer. At height 0 the band // frame is a zero-height strip AT `bottomEdge` (composerTop == bottomEdge), so a // close animates a smooth downward height-collapse into the toolbar/keyboard @@ -1845,9 +1858,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let composerTop = bottomEdge - effectiveComposerHeight let composerFrame = CGRect(x: 0, y: max(0, composerTop), width: width, height: effectiveComposerHeight) // Toolbar's reserved bottom is the composer's top (or the bottom edge with no - // composer), and its reserved top is one button-row band above that. + // composer), and its reserved top is one configured row-stack band above that. let toolbarBottom = effectiveComposerHeight > 0 ? composerTop : bottomEdge - let toolbarReservedTop = toolbarBottom - Self.persistentToolbarHeight + let toolbarReservedTop = toolbarBottom - persistentToolbarHeight let toolbarTop = max(0, toolbarReservedTop) let toolbarFrame = CGRect(x: 0, y: toolbarTop, width: width, height: toolbarBottom - toolbarTop) return (composerFrame, toolbarFrame) @@ -2184,6 +2197,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } deinit { + NotificationCenter.default.removeObserver(self) stopKeyboardHeightAnimation() disposeSurface() } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift index 80ffd63d4e93..bf3ffb6228cd 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift @@ -3,7 +3,8 @@ import Foundation import Observation /// User-editable configuration of the terminal input-accessory bar: which -/// buttons appear, in what order, and any user-defined ``CustomToolbarAction``s. +/// buttons appear, which row they occupy, in what order, and any user-defined +/// ``CustomToolbarAction``s. /// /// Every button on the bar is configurable: the modifier keys (⌃ ⌥ ⌘ ⇧), the zoom /// controls, paste, the shipped insertable shortcuts (Esc, Tab, arrows, the agent @@ -30,14 +31,23 @@ public final class TerminalAccessoryConfiguration { /// UIKit input-accessory bar can rebuild its configurable buttons. public static let didChangeNotification = Notification.Name("cmux.terminal.accessoryConfigurationDidChange") + /// Minimum number of configurable toolbar rows. + public static let minimumRowCount = 1 + /// Maximum number of configurable toolbar rows. + public static let maximumRowCount = 3 + + // v4 schema, keyed by rows of ``ToolbarItemID`` storage keys + JSON custom + // actions. Empty rows are persisted so an explicit row count survives. + private static let rowsDefaultsKey = "cmux.terminal.toolbar.rows.v4" + private static let enabledDefaultsKey = "cmux.terminal.toolbar.enabled.v4" // v3 schema, keyed by ``ToolbarItemID`` storage keys + JSON custom actions. // v3 widened the configurable region to include the modifier/zoom/paste // built-ins that v1/v2 pinned, so its enabled set is authoritative (it knows // those built-ins are hideable); presence of these keys means "skip the // force-enable widening migration". - private static let orderDefaultsKey = "cmux.terminal.toolbar.order.v3" - private static let enabledDefaultsKey = "cmux.terminal.toolbar.enabled.v3" - // Custom actions are schema-stable across v2 and v3, so the v2 key is reused. + private static let legacyV3OrderDefaultsKey = "cmux.terminal.toolbar.order.v3" + private static let legacyV3EnabledDefaultsKey = "cmux.terminal.toolbar.enabled.v3" + // Custom actions are schema-stable across v2-v4, so the v2 key is reused. private static let customDefaultsKey = "cmux.terminal.toolbar.custom.v2" // v2 schema (ToolbarItemID storage keys, configurable region = trailing // shortcuts only), read once to forward-migrate an upgrading user. @@ -48,9 +58,14 @@ public final class TerminalAccessoryConfiguration { private static let legacyV1OrderDefaultsKey = "cmux.terminal.accessory.displayOrder.v1" private static let legacyV1EnabledDefaultsKey = "cmux.terminal.accessory.enabled.v1" - /// The configurable items in the order the user has arranged them, as unified - /// identifiers (built-ins and custom actions together). - public private(set) var displayOrder: [ToolbarItemID] + /// The configurable items arranged into the toolbar rows the user configured, + /// as unified identifiers (built-ins and custom actions together). + public private(set) var displayRows: [[ToolbarItemID]] + + /// The configurable items in row-major order. + public var displayOrder: [ToolbarItemID] { + displayRows.flatMap { $0 } + } /// The subset of ``displayOrder`` currently shown on the bar. public private(set) var enabledSet: Set @@ -86,11 +101,19 @@ public final class TerminalAccessoryConfiguration { // else v1 present → relabel v1→ids, then widen to v3 the same way. // else fresh install → empty saved + nil enabled ⇒ default layout, // which already includes modifiers/zoom/paste. - let savedOrder: [ToolbarItemID] + let savedRows: [[ToolbarItemID]]? + var savedOrder: [ToolbarItemID] = [] let savedEnabled: [ToolbarItemID]? - if let v3Order = defaults.array(forKey: Self.orderDefaultsKey) as? [String] { + let rowCount: Int + if let v4Rows = Self.loadRows(from: defaults) { + savedRows = v4Rows + savedEnabled = (defaults.array(forKey: Self.enabledDefaultsKey) as? [String])? + .compactMap(ToolbarItemID.init(storageKey:)) + rowCount = Self.clampedRowCount(v4Rows.count) + } else if let v3Order = defaults.array(forKey: Self.legacyV3OrderDefaultsKey) as? [String] { + savedRows = nil let order = v3Order.compactMap(ToolbarItemID.init(storageKey:)) - let enabled = (defaults.array(forKey: Self.enabledDefaultsKey) as? [String])? + let enabled = (defaults.array(forKey: Self.legacyV3EnabledDefaultsKey) as? [String])? .compactMap(ToolbarItemID.init(storageKey:)) // ⇧ and Return each became user-configurable after the v3 schema // shipped, so a layout persisted under the v3 keys has no record of @@ -103,7 +126,9 @@ public final class TerminalAccessoryConfiguration { let folded = Self.foldNewlyConfigurableV3(order: order, enabled: enabled, migration: migration) savedOrder = folded.order savedEnabled = folded.enabled + rowCount = Self.minimumRowCount } else if let v2Order = defaults.array(forKey: Self.legacyV2OrderDefaultsKey) as? [String] { + savedRows = nil let widened = migration.widenedToV3( order: v2Order.compactMap(ToolbarItemID.init(storageKey:)), enabled: (defaults.array(forKey: Self.legacyV2EnabledDefaultsKey) as? [String])? @@ -116,7 +141,9 @@ public final class TerminalAccessoryConfiguration { let folded = Self.foldNewlyConfigurableV3(order: widened.order, enabled: widened.enabled, migration: migration) savedOrder = folded.order savedEnabled = folded.enabled + rowCount = Self.minimumRowCount } else if let v1Order = defaults.array(forKey: Self.legacyV1OrderDefaultsKey) as? [Int] { + savedRows = nil let widened = migration.widenedToV3( order: migration.migratedOrder(legacy: v1Order), enabled: migration.migratedEnabled( @@ -130,15 +157,22 @@ public final class TerminalAccessoryConfiguration { let folded = Self.foldNewlyConfigurableV3(order: widened.order, enabled: widened.enabled, migration: migration) savedOrder = folded.order savedEnabled = folded.enabled + rowCount = Self.minimumRowCount } else { - savedOrder = [] + savedRows = nil savedEnabled = nil + rowCount = Self.minimumRowCount } - let layout = reducer.load(savedOrder: savedOrder, savedEnabled: savedEnabled) - self.displayOrder = layout.order + let layout = reducer.load( + savedRows: savedRows, + savedOrder: savedOrder, + savedEnabled: savedEnabled, + rowCount: rowCount + ) + self.displayRows = layout.rows self.enabledSet = layout.enabled - // Persist the normalized (and possibly migrated) layout under the v3 keys + // Persist the normalized (and possibly migrated) layout under the v4 keys // so the migration path runs at most once. persist() } @@ -211,6 +245,25 @@ public final class TerminalAccessoryConfiguration { displayOrder.filter { enabledSet.contains($0) }.compactMap(resolve) } + /// Every configurable item arranged by toolbar row, regardless of + /// shown/hidden state. + public var displayItemRows: [[ResolvedToolbarItem]] { + displayRows.map { row in row.compactMap(resolve) } + } + + /// The shown items arranged by toolbar row — exactly what the toolbar renders, + /// ahead of the fixed trailing "customize" control. + public var enabledItemRows: [[ResolvedToolbarItem]] { + displayRows.map { row in + row.filter { enabledSet.contains($0) }.compactMap(resolve) + } + } + + /// The number of persisted toolbar rows. + public var rowCount: Int { + displayRows.count + } + /// Whether `id` is currently shown on the bar. public func isEnabled(_ id: ToolbarItemID) -> Bool { enabledSet.contains(id) @@ -231,12 +284,34 @@ public final class TerminalAccessoryConfiguration { persistAndNotify() } + /// Reorder the configurable items within a specific toolbar row. + public func moveItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { + apply(reducer.move(from: offsets, to: destination, inRow: rowIndex, in: currentLayout)) + persistAndNotify() + } + + /// Move one configurable item to the end of another toolbar row. + public func moveItem(_ id: ToolbarItemID, toRow rowIndex: Int) { + apply(reducer.move(id, toRow: rowIndex, in: currentLayout)) + persistAndNotify() + } + + /// Change the number of visible toolbar rows. + public func setRowCount(_ rowCount: Int) { + apply(reducer.setRowCount(Self.clampedRowCount(rowCount), in: currentLayout)) + persistAndNotify() + } + /// Reorder the configurable items using a complete desired order. /// /// Unknown identifiers are dropped and any omitted current/configurable ids /// are appended by the reducer load path, matching launch-time normalization. public func reorderItems(_ orderedIDs: [ToolbarItemID]) { - apply(reducer.load(savedOrder: orderedIDs, savedEnabled: Array(enabledSet))) + apply(reducer.load( + savedRows: Self.split(orderedIDs, matchingRowLengthsOf: displayRows), + savedEnabled: Array(enabledSet), + rowCount: rowCount + )) persistAndNotify() } @@ -245,8 +320,9 @@ public final class TerminalAccessoryConfiguration { customActions.append(action) reducer = Self.makeReducer(customActions: customActions) apply(reducer.load( - savedOrder: displayOrder, - savedEnabled: Array(enabledSet) + [action.itemID] + savedRows: displayRows, + savedEnabled: Array(enabledSet) + [action.itemID], + rowCount: rowCount )) persistAndNotify() } @@ -265,7 +341,7 @@ public final class TerminalAccessoryConfiguration { guard customActions.contains(where: { $0.id == id }) else { return } customActions.removeAll { $0.id == id } reducer = Self.makeReducer(customActions: customActions) - apply(reducer.load(savedOrder: displayOrder, savedEnabled: Array(enabledSet))) + apply(reducer.load(savedRows: displayRows, savedEnabled: Array(enabledSet), rowCount: rowCount)) persistAndNotify() } @@ -288,11 +364,11 @@ public final class TerminalAccessoryConfiguration { } private var currentLayout: TerminalAccessoryLayoutReducer.Layout { - .init(order: displayOrder, enabled: enabledSet) + .init(rows: displayRows, enabled: enabledSet) } private func apply(_ layout: TerminalAccessoryLayoutReducer.Layout) { - displayOrder = layout.order + displayRows = layout.rows enabledSet = layout.enabled } @@ -315,8 +391,40 @@ public final class TerminalAccessoryConfiguration { return decoded } + private static func loadRows(from defaults: UserDefaults) -> [[ToolbarItemID]]? { + guard let stored = defaults.array(forKey: Self.rowsDefaultsKey) else { return nil } + return stored.map { row in + guard let storageKeys = row as? [String] else { return [] } + return storageKeys.compactMap(ToolbarItemID.init(storageKey:)) + } + } + + /// Clamp a requested toolbar row count to the supported range. + public static func clampedRowCount(_ rowCount: Int) -> Int { + min(max(rowCount, minimumRowCount), maximumRowCount) + } + + private static func split( + _ order: [ToolbarItemID], + matchingRowLengthsOf rows: [[ToolbarItemID]] + ) -> [[ToolbarItemID]] { + guard !rows.isEmpty else { return [order] } + var result: [[ToolbarItemID]] = [] + var cursor = order.startIndex + for row in rows { + let remaining = order.distance(from: cursor, to: order.endIndex) + let end = order.index(cursor, offsetBy: min(row.count, remaining)) + result.append(Array(order[cursor.. CGFloat { + let rows = CGFloat(TerminalAccessoryConfiguration.clampedRowCount(rowCount)) + return rows * dockedNubSize + max(0, rows - 1) * dockedRowSpacing + } + /// Height reserved by the grid and the composer host for the docked bar. + /// + /// It is the configured row stack (one to three rows) plus + /// ``dockedBottomPadding`` below it. The row stack is pinned to the BOTTOM of + /// this band (minus the padding): when the surface-hosted container grows + /// taller than the reserved band, the controls stay glued to the keyboard top + /// and only the slack ABOVE them grows. + static func dockedButtonRowHeight(rowCount: Int) -> CGFloat { + dockedRowsHeight(rowCount: rowCount) + dockedBottomPadding + } /// Minimum (not fixed) button width. Text buttons (Tab, Esc, ^C, ^D) size to /// their intrinsic content width and only floor here so they hug their label /// plus the comfortable inset; single-glyph modifiers/icons (⌃ ⌥ ⌘, the arrow @@ -295,7 +301,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // Placeholder height until the host positions the bar via // `GhosttySurfaceView.bottomDockFrames()`; sized to the button-row strip so // the pre-layout frame matches the reserved grid height. - container.frame = CGRect(x: 0, y: 0, width: 0, height: Self.dockedButtonRowHeight) + container.frame = CGRect( + x: 0, + y: 0, + width: 0, + height: Self.dockedButtonRowHeight(rowCount: TerminalAccessoryConfiguration.shared.rowCount) + ) let backgroundView = UIView() backgroundView.backgroundColor = Self.monokaiBarColor @@ -311,23 +322,16 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { dismissButton.translatesAutoresizingMaskIntoConstraints = false self.dismissButton = dismissButton - // Scrollable action buttons - let scrollView = UIScrollView() - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.alwaysBounceHorizontal = true - scrollView.translatesAutoresizingMaskIntoConstraints = false - - let stack = UIStackView() - stack.axis = .horizontal - // Tighter inter-button spacing so the keys read as a compact row. - stack.spacing = 4 - stack.alignment = .center - - stack.translatesAutoresizingMaskIntoConstraints = false - accessoryStackView = stack + // Configurable action rows. Each row scrolls horizontally; the rows + // stack vertically so more buttons can stay visible above the keyboard. + let rowsStack = UIStackView() + rowsStack.axis = .vertical + rowsStack.spacing = Self.dockedRowSpacing + rowsStack.alignment = .fill + rowsStack.distribution = .fill + rowsStack.translatesAutoresizingMaskIntoConstraints = false + accessoryRowsStackView = rowsStack populateAccessoryActions() - scrollView.addSubview(stack) // Arrow nub for directional pad let nub = TerminalArrowNubView() @@ -354,7 +358,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { container.addSubview(dismissButton) container.addSubview(nub) container.addSubview(composerButton) - container.addSubview(scrollView) + container.addSubview(rowsStack) let backgroundLeadingConstraint = backgroundView.leadingAnchor.constraint(equalTo: container.leadingAnchor) let backgroundTrailingConstraint = backgroundView.trailingAnchor.constraint(equalTo: container.trailingAnchor) @@ -365,13 +369,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // The bar scrolls horizontally, so the right edge runs flush to the // screen (zero trailing inset). `updateAccessoryLayoutInsets` only adds a // safe-area inset when the surface itself does not reach the window edge. - let scrollTrailingConstraint = scrollView.trailingAnchor.constraint( + let scrollTrailingConstraint = rowsStack.trailingAnchor.constraint( equalTo: container.safeAreaLayoutGuide.trailingAnchor, constant: 0 ) // A short fixed-height strip pinned to the container's BOTTOM (minus - // ``dockedBottomPadding``) that holds the button row. The docked container + // ``dockedBottomPadding``) that holds the configured row stack. The docked container // can be TALLER than this strip, because the host // (`GhosttySurfaceView.bottomDockFrames`) anchors the bar's TOP to the // rendered terminal's bottom and its BOTTOM to the keyboard top, so a @@ -381,8 +385,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // top-pin would let the controls ride UP off the keyboard whenever the // terminal was letterboxed. `dockedBottomPadding` lifts the strip off the // very bottom edge so the controls have breathing room. - let buttonRow = UILayoutGuide() - container.addLayoutGuide(buttonRow) + let buttonRows = UILayoutGuide() + let bottomButtonRow = UILayoutGuide() + container.addLayoutGuide(buttonRows) + container.addLayoutGuide(bottomButtonRow) + let buttonRowsHeightConstraint = buttonRows.heightAnchor.constraint( + equalToConstant: Self.dockedRowsHeight(rowCount: TerminalAccessoryConfiguration.shared.rowCount) + ) NSLayoutConstraint.activate([ backgroundLeadingConstraint, @@ -393,24 +402,26 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // Bottom-pinned (minus the bottom padding) so the controls hug the // keyboard top no matter how tall the container grows; the strip itself // stays exactly the nub height. - buttonRow.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Self.dockedBottomPadding), - buttonRow.leadingAnchor.constraint(equalTo: container.leadingAnchor), - buttonRow.trailingAnchor.constraint(equalTo: container.trailingAnchor), - buttonRow.heightAnchor.constraint(equalToConstant: Self.dockedNubSize), + buttonRows.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Self.dockedBottomPadding), + buttonRows.leadingAnchor.constraint(equalTo: container.leadingAnchor), + buttonRows.trailingAnchor.constraint(equalTo: container.trailingAnchor), + buttonRowsHeightConstraint, - // Every control shares the strip's single centerline. The strip is sized + bottomButtonRow.bottomAnchor.constraint(equalTo: buttonRows.bottomAnchor), + bottomButtonRow.leadingAnchor.constraint(equalTo: buttonRows.leadingAnchor), + bottomButtonRow.trailingAnchor.constraint(equalTo: buttonRows.trailingAnchor), + bottomButtonRow.heightAnchor.constraint(equalToConstant: Self.dockedNubSize), + + // The fixed controls share the bottom row's centerline. That row is sized // to the tallest control (the ``dockedNubSize`` nub), so centering keeps - // all three groups — keyboard button, nub, and the scrollable Ctrl/Esc/Tab - // row — on ONE horizontal line, hugging the keyboard top (the strip is - // bottom-pinned). (Top-pinning the directly-anchored controls instead - // would float them above the scroll row, which is centered inside its own - // scroll view.) + // the keyboard button, nub, composer, and bottom shortcut row aligned + // while any extra rows stack above them. dismissLeadingConstraint, - dismissButton.centerYAnchor.constraint(equalTo: buttonRow.centerYAnchor), + dismissButton.centerYAnchor.constraint(equalTo: bottomButtonRow.centerYAnchor), dismissButton.widthAnchor.constraint(equalToConstant: 32), nub.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 6), - nub.centerYAnchor.constraint(equalTo: buttonRow.centerYAnchor), + nub.centerYAnchor.constraint(equalTo: bottomButtonRow.centerYAnchor), nub.widthAnchor.constraint(equalToConstant: Self.dockedNubSize), nub.heightAnchor.constraint(equalToConstant: Self.dockedNubSize), @@ -420,30 +431,19 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // stack uses, so the bar reads identically to before — only now the // composer can never scroll away. composerButton.leadingAnchor.constraint(equalTo: nub.trailingAnchor, constant: 6), - composerButton.centerYAnchor.constraint(equalTo: buttonRow.centerYAnchor), + composerButton.centerYAnchor.constraint(equalTo: bottomButtonRow.centerYAnchor), - scrollView.leadingAnchor.constraint(equalTo: composerButton.trailingAnchor, constant: 4), + rowsStack.leadingAnchor.constraint(equalTo: composerButton.trailingAnchor, constant: 4), scrollTrailingConstraint, - scrollView.topAnchor.constraint(equalTo: buttonRow.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: buttonRow.bottomAnchor), - - // No vertical margin inside the scroll view: the stack fills the strip - // height so the glass capsules grow to the full section height. The - // bar's breathing room lives BELOW the strip (`dockedBottomPadding`), - // not as a margin that shrinks the buttons. - stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - // Zero trailing content padding so the last button runs to the screen - // edge when scrolled to the end (the bar scrolls horizontally). - stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + rowsStack.topAnchor.constraint(equalTo: buttonRows.topAnchor), + rowsStack.bottomAnchor.constraint(equalTo: buttonRows.bottomAnchor), ]) accessoryBackgroundLeadingConstraint = backgroundLeadingConstraint accessoryBackgroundTrailingConstraint = backgroundTrailingConstraint accessoryDismissLeadingConstraint = dismissLeadingConstraint accessoryScrollTrailingConstraint = scrollTrailingConstraint + accessoryRowsHeightConstraint = buttonRowsHeightConstraint // The cmux iOS app always drives a macOS cmux surface, so default the // accessory to Mac modifiers: retitle Ctrl/Alt to ⌃/⌥ and insert the ⌘ // button. `updateModifierLabels(isMacRemote:)` can still switch this if a @@ -461,7 +461,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// regardless of where the view is hosted. var toolbarView: UIView { terminalAccessoryToolbar } - private weak var accessoryStackView: UIStackView? + private weak var accessoryRowsStackView: UIStackView? + private var accessoryActionStackViews: [UIStackView] = [] + private var accessoryRowsHeightConstraint: NSLayoutConstraint? private var isMacRemote = false #if DEBUG @@ -474,7 +476,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// ``GhosttySurfaceView.composerDockProbeValue`` so it lands in the XCUITest /// failure message with no log plumbing. var accessoryLayoutDiagnostics: String { - let scroll = accessoryStackView?.superview as? UIScrollView + let scroll = accessoryActionStackViews.first?.superview as? UIScrollView let win = window let scrollOffsetX = scroll.map { Int($0.contentOffset.x) } ?? -1 let scrollContentW = scroll.map { Int($0.contentSize.width) } ?? -1 @@ -505,25 +507,30 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // bar's leading inset, so the rightmost button hugs the screen edge. accessoryScrollTrailingConstraint?.constant = -rightInset - if accessoryStackView != nil { + if accessoryRowsStackView != nil { terminalAccessoryToolbar.setNeedsLayout() terminalAccessoryToolbar.layoutIfNeeded() } } - /// Build (or rebuild) the SCROLLABLE button row: the user's configured order + /// Build (or rebuild) the scrollable button rows: the user's configured rows /// (modifiers, zoom, paste, shortcuts, and custom actions all reorderable - /// together), followed by the fixed trailing HIDE and "customize" controls. + /// together), followed on the bottom row by the fixed trailing HIDE and + /// "customize" controls. /// The composer toggle is NOT here — it is pinned in the container outside /// the scroll view (see ``terminalAccessoryToolbar``). The ⌘ item is rendered /// only when driving a Mac remote. Safe to call repeatedly; it clears the - /// stack first. + /// row stack first. private func populateAccessoryActions() { - guard let stack = accessoryStackView else { return } - for view in stack.arrangedSubviews { - stack.removeArrangedSubview(view) + guard let rowsStack = accessoryRowsStackView else { return } + for view in rowsStack.arrangedSubviews { + rowsStack.removeArrangedSubview(view) view.removeFromSuperview() } + accessoryActionStackViews.removeAll() + + let configuration = TerminalAccessoryConfiguration.shared + updateAccessoryRowsHeight(rowCount: configuration.rowCount) // The composer toggle is NOT added here: it is pinned directly in the // container (see ``terminalAccessoryToolbar``), OUTSIDE this scrollable @@ -532,24 +539,35 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // dependent), so a repopulate must not re-add or rebuild it. // // The user-configurable region: built-in shortcuts/modifiers/zoom/paste - // and custom actions, all in the user's saved order. - for item in TerminalAccessoryConfiguration.shared.enabledItems { - switch item { - case let .builtin(action): - // ⌘ only makes sense against a Mac remote; skip it otherwise - // (it stays in the saved order, just unrendered, so flipping the - // remote re-shows it in place). - if action == .command && !isMacRemote { continue } - stack.addArrangedSubview(makeAccessoryButton(for: action)) - case let .custom(custom): - stack.addArrangedSubview(makeCustomAccessoryButton(for: custom)) + // and custom actions, all in the user's saved rows and order. + let rowCount = max(1, configuration.rowCount) + let itemRows = configuration.enabledItemRows + for rowIndex in 0.. (UIScrollView, UIStackView) { + let scrollView = UIScrollView() + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.heightAnchor.constraint(equalToConstant: Self.dockedNubSize).isActive = true + + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 4 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + return (scrollView, stack) + } + + private func updateAccessoryRowsHeight(rowCount: Int) { + accessoryRowsHeightConstraint?.constant = Self.dockedRowsHeight(rowCount: rowCount) + if accessoryRowsHeightConstraint != nil { + terminalAccessoryToolbar.frame.size.height = Self.dockedButtonRowHeight(rowCount: rowCount) + } + } + /// Disarm the active modifier if its bar button is no longer rendered, so a /// hidden (or non-Mac-remote ⌘) modifier can never stay invisibly armed. private func reconcileArmedModifierVisibility() { guard let armed = modifierState.armedModifier, let action = Self.accessoryAction(for: armed) else { return } - let renderedActions = (accessoryStackView?.arrangedSubviews ?? []).compactMap { view -> TerminalInputAccessoryAction? in + let renderedActions = accessoryActionStackViews.flatMap(\.arrangedSubviews).compactMap { view -> TerminalInputAccessoryAction? in guard let button = view as? AccessoryActionButton, case let .builtin(builtinAction) = button.item else { return nil } return builtinAction @@ -587,7 +637,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { @objc private func handleAccessoryConfigurationChanged() { // Only rebuild once the bar exists; otherwise the lazy build picks up // the new configuration on first use. - guard accessoryStackView != nil else { return } + guard accessoryRowsStackView != nil else { return } populateAccessoryActions() applyModifierPresentation() terminalAccessoryToolbar.setNeedsLayout() @@ -608,11 +658,10 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// button's armed/sticky style. Split out of ``updateModifierLabels(isMacRemote:)`` /// so a configuration-driven rebuild can re-apply it without toggling the flag. private func applyModifierPresentation() { - guard let stack = accessoryStackView else { return } // Restyle every visible button for the current remote (built-in titles // depend on `isMacRemote`) and its armed/sticky state. Custom actions // never arm. - for case let button as AccessoryActionButton in stack.arrangedSubviews { + for case let button as AccessoryActionButton in accessoryActionStackViews.flatMap(\.arrangedSubviews) { let armed: Bool let sticky: Bool if case let .builtin(action) = button.item { @@ -1179,8 +1228,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } private func refreshAccessoryButtonStyles() { - guard let stack = accessoryStackView else { return } - for case let button as AccessoryActionButton in stack.arrangedSubviews { + for case let button as AccessoryActionButton in accessoryActionStackViews.flatMap(\.arrangedSubviews) { // Only built-in modifier keys arm; custom actions always render normal. let armed: Bool let sticky: Bool diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift index 2f0a9e9d30a3..e4ba4fd1a56f 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift @@ -1,16 +1,14 @@ public import Foundation /// Pure, `Sendable` reducer for the terminal input-accessory bar's configurable -/// region: which insertable shortcuts are shown and in what order. +/// region: which shortcuts are shown, which row they occupy, and their order. /// -/// The terminal accessory bar has two regions. The leading region (modifier and -/// zoom controls) is structural and pinned, so it is never modeled here. The -/// trailing region is the user-configurable list of insertable shortcuts (Esc, -/// Tab, arrows, `$`, `/`, `@`, `^C`, the agent launchers, …). This reducer owns -/// the *logic* for that trailing region — load/merge/forward-compat, enable -/// toggling, reordering, and reset — as pure transformations over the raw `Int` -/// identifiers of those actions, so it stays decoupled from the UIKit-gated -/// `TerminalInputAccessoryAction` enum and is testable from `swift test`. +/// The terminal accessory bar exposes every shortcut/modifier/paste/zoom action +/// as user-configurable. This reducer owns the logic for that region — +/// load/merge/forward-compat, enable toggling, row-count changes, reordering, +/// row assignment, and reset — as pure transformations over opaque identifiers, +/// so it stays decoupled from the UIKit-gated `TerminalInputAccessoryAction` +/// enum and is testable from `swift test`. /// /// Identifiers are opaque, `Hashable` values supplied by the caller. The reducer /// never invents identifiers: every value it returns is drawn from the @@ -22,11 +20,11 @@ public import Foundation /// /// ```swift /// let reducer = TerminalAccessoryLayoutReducer(configurable: [0, 1, 2, 3]) -/// var layout = reducer.load(savedOrder: [2, 0], savedEnabled: nil) -/// // layout.order == [2, 0, 1, 3] (saved first, then forward-compat append) +/// var layout = reducer.load(savedRows: [[2, 0]], savedEnabled: nil, rowCount: 2) +/// // layout.rows == [[2, 0], [1, 3]] (saved first, then forward-compat append) /// // layout.enabled == [0, 1, 2, 3] (nil enabled ⇒ everything on first launch) /// layout = reducer.setEnabled(1, false, in: layout) -/// // layout.visibleOrder == [2, 0, 3] +/// // layout.visibleRows == [[2, 0], [3]] /// ``` public struct TerminalAccessoryLayoutReducer: Sendable { /// The configurable action identifiers in canonical order. This is the @@ -74,8 +72,11 @@ public struct TerminalAccessoryLayoutReducer: Sendable /// An immutable snapshot of the configurable region's state. public struct Layout: Equatable, Sendable { - /// Every configurable identifier in the user's arranged order. - public let order: [ID] + /// Every configurable identifier in the user's arranged rows. + /// + /// Each identifier appears at most once. Empty rows are preserved so the + /// user's configured row count survives even when a row has no actions. + public let rows: [[ID]] /// The subset of ``order`` currently shown on the bar. public let enabled: Set @@ -85,14 +86,33 @@ public struct TerminalAccessoryLayoutReducer: Sendable /// - order: The configurable identifiers in display order. /// - enabled: The identifiers currently shown. public init(order: [ID], enabled: Set) { - self.order = order + self.rows = [order] + self.enabled = enabled + } + + /// Creates a row-aware layout snapshot. + /// + /// - Parameters: + /// - rows: The configurable identifiers arranged into toolbar rows. + /// - enabled: The identifiers currently shown. + public init(rows: [[ID]], enabled: Set) { + self.rows = rows.isEmpty ? [[]] : rows self.enabled = enabled } - /// The enabled identifiers in display order — exactly what the toolbar's - /// configurable region renders, after the pinned leading buttons. + /// Every configurable identifier in row-major display order. + public var order: [ID] { + rows.flatMap { $0 } + } + + /// The enabled identifiers in row-major display order. public var visibleOrder: [ID] { - order.filter { enabled.contains($0) } + visibleRows.flatMap { $0 } + } + + /// The enabled identifiers arranged by toolbar row. + public var visibleRows: [[ID]] { + rows.map { row in row.filter { enabled.contains($0) } } } } @@ -109,11 +129,42 @@ public struct TerminalAccessoryLayoutReducer: Sendable /// - Returns: A normalized ``Layout`` containing exactly the configurable /// identifiers. public func load(savedOrder: [ID], savedEnabled: [ID]?) -> Layout { - var order = savedOrder.filter { configurableSet.contains($0) } - var seen = Set(order) - for identifier in defaultOrder where !seen.contains(identifier) { - order.append(identifier) - seen.insert(identifier) + load(savedRows: [savedOrder], savedEnabled: savedEnabled, rowCount: 1) + } + + /// Builds a row-aware layout from persisted values, dropping unknown + /// identifiers, de-duplicating across rows, preserving empty rows, and + /// appending any configurable action not yet persisted. + /// + /// Forward-compatible identifiers are appended to the last row so an existing + /// custom row arrangement remains stable while still surfacing new actions. + /// If `rowCount` is smaller than the saved row count, overflow rows are merged + /// into the last retained row. If it is larger, empty rows are appended. + /// + /// - Parameters: + /// - savedRows: The persisted row arrangement. + /// - savedEnabled: The persisted enabled set, or `nil` on first launch. + /// `nil` means "show everything"; an empty array means the user hid every + /// shortcut. + /// - rowCount: The requested number of toolbar rows. Values below 1 are + /// clamped to 1. + /// - Returns: A normalized ``Layout`` containing exactly the configurable + /// identifiers. + public func load(savedRows: [[ID]], savedEnabled: [ID]?, rowCount: Int) -> Layout { + let desiredRowCount = max(1, rowCount) + let persistedRows = savedRows.isEmpty ? [defaultOrder] : savedRows + var rows = Array(repeating: [ID](), count: desiredRowCount) + var seen = Set() + for (sourceIndex, sourceRow) in persistedRows.enumerated() { + let rowIndex = min(sourceIndex, desiredRowCount - 1) + for identifier in sourceRow + where configurableSet.contains(identifier) && seen.insert(identifier).inserted { + rows[rowIndex].append(identifier) + } + } + + for identifier in defaultOrder where seen.insert(identifier).inserted { + rows[desiredRowCount - 1].append(identifier) } let enabled: Set @@ -122,7 +173,30 @@ public struct TerminalAccessoryLayoutReducer: Sendable } else { enabled = configurableSet } - return Layout(order: order, enabled: enabled) + return Layout(rows: rows, enabled: enabled) + } + + /// Builds a row-aware layout from persisted rows, using `defaultOrder` as the + /// fresh-install arrangement when no rows were saved. + /// + /// - Parameters: + /// - savedRows: The persisted row arrangement, or `nil` when not present. + /// - savedOrder: The legacy flat saved order to migrate when `savedRows` is + /// absent. + /// - savedEnabled: The persisted enabled set, or `nil` on first launch. + /// - rowCount: The requested number of toolbar rows. + /// - Returns: A normalized ``Layout`` containing exactly the configurable + /// identifiers. + public func load( + savedRows: [[ID]]?, + savedOrder: [ID], + savedEnabled: [ID]?, + rowCount: Int + ) -> Layout { + if let savedRows { + return load(savedRows: savedRows, savedEnabled: savedEnabled, rowCount: rowCount) + } + return load(savedRows: savedOrder.isEmpty ? [defaultOrder] : [savedOrder], savedEnabled: savedEnabled, rowCount: rowCount) } /// Returns `layout` with `identifier` shown or hidden. A no-op for @@ -137,7 +211,7 @@ public struct TerminalAccessoryLayoutReducer: Sendable guard configurableSet.contains(identifier) else { return layout } var enabled = layout.enabled if isEnabled { enabled.insert(identifier) } else { enabled.remove(identifier) } - return Layout(order: layout.order, enabled: enabled) + return Layout(rows: layout.rows, enabled: enabled) } /// Returns `layout` with the configurable actions reordered. @@ -163,11 +237,95 @@ public struct TerminalAccessoryLayoutReducer: Sendable } let insertionIndex = destination - movedIndices.filter { $0 < destination }.count order.insert(contentsOf: moved, at: max(0, min(insertionIndex, order.count))) - return Layout(order: order, enabled: layout.enabled) + return Layout(rows: Self.split(order, matchingRowLengthsOf: layout.rows), enabled: layout.enabled) } - /// The default layout: ``defaultOrder`` with every shortcut shown. + /// Returns `layout` with items moved within one toolbar row. + /// + /// `offsets`/`destination` follow the SwiftUI `onMove` contract: indices into + /// the selected row. + /// + /// - Parameters: + /// - offsets: The row-local indices being moved. + /// - destination: The row-local insertion index. + /// - rowIndex: The row to reorder. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func move(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int, in layout: Layout) -> Layout { + guard layout.rows.indices.contains(rowIndex) else { return layout } + var rows = layout.rows + var row = rows[rowIndex] + var validOffsets = IndexSet() + for offset in offsets where row.indices.contains(offset) { + validOffsets.insert(offset) + } + guard !validOffsets.isEmpty else { return layout } + let movedIndices = validOffsets.sorted() + let moved = movedIndices.map { row[$0] } + for index in movedIndices.reversed() { + row.remove(at: index) + } + let insertionIndex = destination - movedIndices.filter { $0 < destination }.count + row.insert(contentsOf: moved, at: max(0, min(insertionIndex, row.count))) + rows[rowIndex] = row + return Layout(rows: rows, enabled: layout.enabled) + } + + /// Returns `layout` with `identifier` moved to the end of another row. + /// + /// A no-op for unknown identifiers or invalid row indices. + /// + /// - Parameters: + /// - identifier: The action identifier to move. + /// - rowIndex: The destination row. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func move(_ identifier: ID, toRow rowIndex: Int, in layout: Layout) -> Layout { + guard configurableSet.contains(identifier), layout.rows.indices.contains(rowIndex) else { return layout } + var rows = layout.rows.map { row in row.filter { $0 != identifier } } + rows[rowIndex].append(identifier) + return Layout(rows: rows, enabled: layout.enabled) + } + + /// Returns `layout` with its toolbar row count changed. + /// + /// Reducing the count preserves every action by merging overflow rows into + /// the last retained row. Increasing the count appends empty rows. + /// + /// - Parameters: + /// - rowCount: The requested row count. Values below 1 are clamped to 1. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func setRowCount(_ rowCount: Int, in layout: Layout) -> Layout { + let desiredRowCount = max(1, rowCount) + var rows = layout.rows.isEmpty ? [[]] : layout.rows + if rows.count > desiredRowCount { + let keptPrefix = rows.prefix(desiredRowCount - 1) + let mergedTail = rows.dropFirst(desiredRowCount - 1).flatMap { $0 } + rows = Array(keptPrefix) + [mergedTail] + } else if rows.count < desiredRowCount { + rows.append(contentsOf: Array(repeating: [], count: desiredRowCount - rows.count)) + } + return Layout(rows: rows, enabled: layout.enabled) + } + + /// The default layout: ``defaultOrder`` in one row, with every shortcut shown. public func defaultLayout() -> Layout { Layout(order: defaultOrder, enabled: configurableSet) } + + private static func split(_ order: [ID], matchingRowLengthsOf rows: [[ID]]) -> [[ID]] { + guard !rows.isEmpty else { return [order] } + var result: [[ID]] = [] + var cursor = order.startIndex + for row in rows { + let end = order.index(cursor, offsetBy: min(row.count, order.distance(from: cursor, to: order.endIndex))) + result.append(Array(order[cursor...Layout( + rows: [[0], [1, 2], [3]], + enabled: Set([0, 1, 2, 3]) + ) + let reduced = reducer.setRowCount(2, in: layout) + #expect(reduced.rows == [[0], [1, 2, 3]]) + } + + @Test("row count increase appends empty rows") + func increasingRowCountAppendsEmptyRows() { + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1, 2, 3]], + enabled: Set([0, 1, 2, 3]) + ) + let expanded = reducer.setRowCount(3, in: layout) + #expect(expanded.rows == [[0, 1, 2, 3], [], []]) + } + + @Test("row-local move reorders only that row") + func rowLocalMove() { + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1], [2, 3]], + enabled: Set([0, 1, 2, 3]) + ) + let moved = reducer.move(from: IndexSet(integer: 0), to: 2, inRow: 1, in: layout) + #expect(moved.rows == [[0, 1], [3, 2]]) + } + + @Test("moving an item to another row preserves enabled state") + func moveItemToRow() { + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1], [2, 3]], + enabled: Set([0, 2]) + ) + let moved = reducer.move(1, toRow: 1, in: layout) + #expect(moved.rows == [[0], [2, 3, 1]]) + #expect(moved.enabled == Set([0, 2])) + } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 57f04d5a16c2..6db039126c73 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4523,6 +4523,91 @@ } } }, + "mobile.shortcuts.rows.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add rows to keep more buttons visible above the keyboard." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行を追加すると、キーボード上に表示できるボタンが増えます。" + } + } + } + }, + "mobile.shortcuts.rows.header": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toolbar Rows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツールバー行" + } + } + } + }, + "mobile.shortcuts.rows.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行" + } + } + } + }, + "mobile.shortcuts.rows.movePicker": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Row" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行へ移動" + } + } + } + }, + "mobile.shortcuts.rows.rowTitleFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Row %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d行目" + } + } + } + }, "mobile.shortcuts.title": { "extractionState": "manual", "localizations": { From 32092513c5ac71f9428752fa05aafe94f2fcde69 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 13:20:54 -0700 Subject: [PATCH 02/16] Update Swift file length budget --- .github/swift-file-length-budget.tsv | 36 +++++++++++++--------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7b3ae3f3ab43..de9ca184423d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,23 +2,23 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34629 CLI/cmux.swift -17827 Sources/AppDelegate.swift -16128 Sources/ContentView.swift +17826 Sources/AppDelegate.swift +16109 Sources/ContentView.swift 13832 Sources/TerminalController.swift -12878 Sources/Workspace.swift +12838 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -11578 Sources/Panels/BrowserPanel.swift +11567 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift -7986 Sources/Panels/BrowserPanelView.swift +7952 Sources/Panels/BrowserPanelView.swift 7366 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift -6916 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +6876 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift 6183 Sources/TabManager.swift -6084 Sources/TextBoxInput.swift +6077 Sources/TextBoxInput.swift 5915 cmuxTests/TerminalAndGhosttyTests.swift 5573 cmuxTests/BrowserConfigTests.swift 4487 Sources/Panels/FilePreviewPanel.swift @@ -28,11 +28,11 @@ 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift -3745 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +3702 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift 3397 Sources/CmuxConfig.swift 3364 cmuxTests/TabManagerSessionSnapshotTests.swift -3058 Sources/Update/UpdateTitlebarAccessory.swift +3053 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift 2606 Sources/KeyboardShortcutSettings.swift @@ -47,9 +47,9 @@ 2091 cmuxTests/ShortcutAndCommandPaletteTests.swift 2078 Sources/SessionPersistence.swift 1952 Sources/KeyboardShortcutSettingsFileStore.swift -1949 Sources/Panels/BrowserWebAuthnSupport.swift 1945 Sources/RestorableAgentSession.swift 1900 cmuxTests/NotificationAndMenuBarTests.swift +1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift 1748 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift @@ -57,13 +57,13 @@ 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift -1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1629 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift -1452 Sources/RemoteTmuxControlConnection.swift 1447 Sources/FileExplorerStore.swift +1433 Sources/RemoteTmuxControlConnection.swift 1384 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift @@ -77,8 +77,8 @@ 1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift 1204 cmuxTests/FileExplorerStoreTests.swift 1197 cmuxTests/CodexAppServerSessionTests.swift -1169 Sources/RemoteTmuxController.swift 1166 Sources/VaultAgentProcessScanner.swift +1164 Sources/RemoteTmuxController.swift 1161 cmuxTests/SidebarOrderingTests.swift 1144 cmuxTests/PiVaultAgentPersistenceTests.swift 1121 cmuxTests/AgentHibernationTests.swift @@ -101,16 +101,16 @@ 905 Sources/CmuxSSHURLRequest.swift 899 Sources/Panels/MarkdownWebRenderer.swift 885 Sources/Panels/TerminalPanel.swift -878 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift +877 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 830 Sources/TaskManagerTypes.swift -825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift 824 Sources/MainWindowFocusController.swift +813 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift 810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift 803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift 802 Sources/WorkspaceContentView.swift @@ -127,7 +127,7 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -725 Sources/Panels/BrowserPopupWindowController.swift +724 Sources/Panels/BrowserPopupWindowController.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift @@ -185,7 +185,6 @@ 572 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift -562 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift 560 cmuxTests/CLISSHPTYResizeInputTests.swift @@ -222,11 +221,10 @@ 509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift 507 Sources/TerminalControllerTopSupport.swift 506 Sources/App/MainWindowVisibilityController.swift -505 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift 505 cmuxUITests/DisplayResolutionRegressionUITests.swift 504 cmuxTests/TerminalNotificationSocketActionTests.swift 503 Sources/Settings/ConfigSource.swift 502 Sources/CmuxEventPublishing.swift 502 Sources/RemoteTmuxSessionMirror.swift -501 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +500 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift 500 Sources/KeyboardShortcutRecorder.swift From 5f86d83cc30c0bc07caff1f58565be22a7d0bdc6 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 13:34:26 -0700 Subject: [PATCH 03/16] Address iOS toolbar row review --- .github/swift-file-length-budget.tsv | 2 +- .../TerminalShortcutRowSection.swift | 9 ++++ .../TerminalShortcutsSettingsView.swift | 34 +++++++------- .../TerminalAccessoryConfiguration.swift | 39 +++++---------- .../TerminalInputTextView.swift | 3 -- .../TerminalAccessoryLayoutReducer.swift | 47 +++++++++++++++++++ .../TerminalAccessoryLayoutReducerTests.swift | 14 ++++++ 7 files changed, 101 insertions(+), 47 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutRowSection.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index f545da1402a8..20179336f393 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -57,7 +57,7 @@ 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift -1629 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1626 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutRowSection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutRowSection.swift new file mode 100644 index 000000000000..76b0262ee8a6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutRowSection.swift @@ -0,0 +1,9 @@ +#if os(iOS) +import CmuxMobileTerminal + +struct TerminalShortcutRowSection: Identifiable { + let id: String + let index: Int + let items: [ResolvedToolbarItem] +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift index 412c69839634..9a82c635189d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift @@ -51,18 +51,18 @@ struct TerminalShortcutsSettingsView: View { )) } - ForEach(displayedItemRows.indices, id: \.self) { rowIndex in + ForEach(displayedRowSections) { rowSection in Section { - ForEach(displayedItemRows[rowIndex]) { item in - row(for: item, rowIndex: rowIndex) + ForEach(rowSection.items) { item in + row(for: item, rowIndex: rowSection.index) } .onMove { offsets, destination in - moveDisplayedItems(from: offsets, to: destination, inRow: rowIndex) + moveDisplayedItems(from: offsets, to: destination, inRow: rowSection.index) } } header: { - Text(rowTitle(rowIndex)) + Text(rowTitle(rowSection.index)) } footer: { - if rowIndex == displayedItemRows.count - 1 { + if rowSection.index == displayedRowSections.count - 1 { Text(scope.footer) } } @@ -199,6 +199,16 @@ struct TerminalShortcutsSettingsView: View { configuration.displayItemRows.map { row in row.filter(scope.includes) } } + private var displayedRowSections: [TerminalShortcutRowSection] { + displayedItemRows.enumerated().map { index, items in + TerminalShortcutRowSection( + id: "terminal-shortcuts-row-\(index)", + index: index, + items: items + ) + } + } + private func rowIndex(for id: ToolbarItemID) -> Int? { configuration.displayRows.firstIndex { row in row.contains(id) } } @@ -211,21 +221,11 @@ struct TerminalShortcutsSettingsView: View { } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int) { - guard scope != .terminal else { - configuration.moveItems(from: offsets, to: destination) - return - } - let visibleIDs = displayedItems.map(\.id) let visibleSet = Set(visibleIDs) var reorderedVisibleIDs = visibleIDs reorderedVisibleIDs.move(fromOffsets: offsets, toOffset: destination) - var visibleIterator = reorderedVisibleIDs.makeIterator() - let reorderedFullIDs = configuration.displayOrder.map { id in - guard visibleSet.contains(id) else { return id } - return visibleIterator.next() ?? id - } - configuration.reorderItems(reorderedFullIDs) + configuration.reorderItems(reorderedVisibleIDs, limitedTo: visibleSet) } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift index bf3ffb6228cd..fb748682c4ff 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift @@ -302,16 +302,22 @@ public final class TerminalAccessoryConfiguration { persistAndNotify() } - /// Reorder the configurable items using a complete desired order. + /// Reorder the configurable items inside their current toolbar rows. /// /// Unknown identifiers are dropped and any omitted current/configurable ids - /// are appended by the reducer load path, matching launch-time normalization. + /// keep their current relative order. Moving an item between rows must go + /// through ``moveItem(_:toRow:)`` so row assignment never changes implicitly. public func reorderItems(_ orderedIDs: [ToolbarItemID]) { - apply(reducer.load( - savedRows: Self.split(orderedIDs, matchingRowLengthsOf: displayRows), - savedEnabled: Array(enabledSet), - rowCount: rowCount - )) + apply(reducer.reorder(orderedIDs, limitedTo: Set(displayOrder), in: currentLayout)) + persistAndNotify() + } + + /// Reorder a scoped subset of configurable items inside their current rows. + /// + /// This keeps every non-scoped item in its exact row position and only reorders + /// `scopedIDs` among the scoped slots in the row each item already occupies. + public func reorderItems(_ orderedIDs: [ToolbarItemID], limitedTo scopedIDs: Set) { + apply(reducer.reorder(orderedIDs, limitedTo: scopedIDs, in: currentLayout)) persistAndNotify() } @@ -404,25 +410,6 @@ public final class TerminalAccessoryConfiguration { min(max(rowCount, minimumRowCount), maximumRowCount) } - private static func split( - _ order: [ToolbarItemID], - matchingRowLengthsOf rows: [[ToolbarItemID]] - ) -> [[ToolbarItemID]] { - guard !rows.isEmpty else { return [order] } - var result: [[ToolbarItemID]] = [] - var cursor = order.startIndex - for row in rows { - let remaining = order.distance(from: cursor, to: order.endIndex) - let end = order.index(cursor, offsetBy: min(row.count, remaining)) - result.append(Array(order[cursor..: Sendable return Layout(rows: Self.split(order, matchingRowLengthsOf: layout.rows), enabled: layout.enabled) } + /// Returns `layout` with a scoped set of identifiers reordered inside their current rows. + /// + /// Use this when a settings surface shows only part of the toolbar. Identifiers + /// outside `scopedIDs` keep their exact row positions, and scoped identifiers are + /// reordered only among the scoped slots in the row they already occupy. Moving + /// an item between rows must go through ``move(_:toRow:in:)``. + /// + /// - Parameters: + /// - orderedIDs: The desired row-major order for the scoped identifiers. + /// - scopedIDs: The identifiers the caller's current surface is allowed to move. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func reorder(_ orderedIDs: [ID], limitedTo scopedIDs: Set, in layout: Layout) -> Layout { + let validScopedIDs = scopedIDs.intersection(configurableSet) + guard !validScopedIDs.isEmpty else { return layout } + + var seen = Set() + let desiredScopedOrder = orderedIDs.filter { identifier in + configurableSet.contains(identifier) + && validScopedIDs.contains(identifier) + && seen.insert(identifier).inserted + } + guard !desiredScopedOrder.isEmpty else { return layout } + + var rows = layout.rows + for rowIndex in rows.indices { + let row = rows[rowIndex] + let rowScopedIDs = Set(row.filter { validScopedIDs.contains($0) }) + guard !rowScopedIDs.isEmpty else { continue } + + var rowSeen = Set() + var rowScopedOrder = desiredScopedOrder.filter { identifier in + rowScopedIDs.contains(identifier) && rowSeen.insert(identifier).inserted + } + for identifier in row where rowScopedIDs.contains(identifier) && rowSeen.insert(identifier).inserted { + rowScopedOrder.append(identifier) + } + + var iterator = rowScopedOrder.makeIterator() + rows[rowIndex] = row.map { identifier in + guard rowScopedIDs.contains(identifier) else { return identifier } + return iterator.next() ?? identifier + } + } + return Layout(rows: rows, enabled: layout.enabled) + } + /// Returns `layout` with items moved within one toolbar row. /// /// `offsets`/`destination` follow the SwiftUI `onMove` contract: indices into diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift index 020f524fe423..244bd8910f65 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift @@ -141,6 +141,20 @@ struct TerminalAccessoryLayoutReducerTests { #expect(moved.rows == [[0, 1], [3, 2]]) } + @Test("scoped reorder preserves terminal rows and non-scoped positions") + func scopedReorderPreservesRows() { + let scopedReducer = TerminalAccessoryLayoutReducer(configurable: [0, 1, 2, 3, 4, 5]) + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1, 2], [3, 4, 5]], + enabled: Set([0, 1, 2, 3, 4, 5]) + ) + + let moved = scopedReducer.reorder([5, 3, 0, 2], limitedTo: Set([0, 2, 3, 5]), in: layout) + + #expect(moved.rows == [[0, 1, 2], [5, 4, 3]]) + #expect(moved.enabled == layout.enabled) + } + @Test("moving an item to another row preserves enabled state") func moveItemToRow() { let layout = TerminalAccessoryLayoutReducer.Layout( From c38e281cd83577fd64e00ebc77c13ad22f1f12e3 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 15:29:19 -0700 Subject: [PATCH 04/16] Update toolbar migration tests for v4 rows --- .../TerminalAccessoryConfigurationTests.swift | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift index cb04c8a51745..844d21cd12be 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift @@ -24,6 +24,13 @@ struct TerminalAccessoryConfigurationTests { private func id(_ action: TerminalInputAccessoryAction) -> ToolbarItemID { action.itemID } + private func persistedV4Rows(in defaults: UserDefaults) -> [[String]]? { + guard let rows = defaults.array(forKey: "cmux.terminal.toolbar.rows.v4") else { return nil } + return rows.map { row in + (row as? [String]) ?? [] + } + } + // MARK: - Gating test #1: fresh-install default order @Test("fresh install puts modifiers (incl. ⇧) at the front and zoom at the back, all shown") @@ -176,24 +183,23 @@ struct TerminalAccessoryConfigurationTests { #expect(config.isEnabled(id(.zoomIn))) } - @Test("an upgraded config re-persists under the v3 keys so the migration runs once") - func migrationPersistsUnderV3Keys() { + @Test("an upgraded config re-persists under the v4 rows so the migration runs once") + func migrationPersistsUnderV4Rows() throws { let defaults = freshDefaults() defaults.set([id(.tab).storageKey], forKey: "cmux.terminal.toolbar.order.v2") defaults.set([id(.tab).storageKey], forKey: "cmux.terminal.toolbar.enabled.v2") _ = TerminalAccessoryConfiguration(defaults: defaults) - // After init, v3 keys exist; a second load takes the v3 path (no second + // After init, v4 rows exist; a second load takes the v4 path (no second // force-enable), so hiding a modifier then would persist. - let v3Order = defaults.array(forKey: "cmux.terminal.toolbar.order.v3") as? [String] - #expect(v3Order != nil) - #expect(v3Order?.contains(id(.control).storageKey) == true) + let v4Rows = try #require(persistedV4Rows(in: defaults)) + #expect(v4Rows.flatMap { $0 }.contains(id(.control).storageKey)) let reloaded = TerminalAccessoryConfiguration(defaults: defaults) reloaded.setEnabled(id(.control), false) let reloadedAgain = TerminalAccessoryConfiguration(defaults: defaults) - // The v3 path honored the hidden modifier rather than re-forcing it on. + // The v4 path honored the hidden modifier rather than re-forcing it on. #expect(!reloadedAgain.isEnabled(id(.control))) } @@ -381,8 +387,8 @@ struct TerminalAccessoryConfigurationTests { #expect(!config.isEnabled(id(.returnKey))) } - @Test("the Return fold re-persists under v3 keys so it runs once") - func returnFoldPersistsUnderV3Keys() { + @Test("the Return fold re-persists under v4 rows so it runs once") + func returnFoldPersistsUnderV4Rows() throws { let defaults = freshDefaults() let preReturn: [TerminalInputAccessoryAction] = [.control, .alternate, .command, .shift, .paste, .tab, .escape] defaults.set(preReturn.map { id($0).storageKey }, forKey: "cmux.terminal.toolbar.order.v3") @@ -390,10 +396,10 @@ struct TerminalAccessoryConfigurationTests { _ = TerminalAccessoryConfiguration(defaults: defaults) - // After init, Return lives in the persisted v3 order, so the next load + // After init, Return lives in the persisted v4 rows, so the next load // takes the no-op path and a later hide would persist. - let v3Order = defaults.array(forKey: "cmux.terminal.toolbar.order.v3") as? [String] - #expect(v3Order?.contains(id(.returnKey).storageKey) == true) + let v4Rows = try #require(persistedV4Rows(in: defaults)) + #expect(v4Rows.flatMap { $0 }.contains(id(.returnKey).storageKey)) } @Test("a v2 upgrade also surfaces Return force-enabled") From 062129ce66c62673b9ddf20aa1ee412f937ff235 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 15:47:23 -0700 Subject: [PATCH 05/16] Add regression test for same-row toolbar moves --- .../TerminalAccessoryConfigurationTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift index 844d21cd12be..f89a161fd7f3 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift @@ -107,6 +107,19 @@ struct TerminalAccessoryConfigurationTests { #expect(reloaded.displayOrder.last == id(.control)) } + @Test("selecting an item's current toolbar row preserves row order") + func movingItemToCurrentRowIsNoOp() { + let config = TerminalAccessoryConfiguration(defaults: freshDefaults()) + config.setRowCount(2) + config.moveItem(id(.tab), toRow: 1) + config.moveItem(id(.escape), toRow: 1) + let rowsBeforeSameRowMove = config.displayRows + + config.moveItem(id(.tab), toRow: 1) + + #expect(config.displayRows == rowsBeforeSameRowMove) + } + @Test("hiding a modifier persists across reload and keeps it in the order") func hideModifierPersists() { let defaults = freshDefaults() From 84722cf3e08d05844c1ca0f63b7e18b4036b7062 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 15:48:01 -0700 Subject: [PATCH 06/16] Avoid same-row toolbar move churn --- .../CmuxMobileTerminal/TerminalAccessoryConfiguration.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift index fb748682c4ff..47072609cb82 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift @@ -291,7 +291,12 @@ public final class TerminalAccessoryConfiguration { } /// Move one configurable item to the end of another toolbar row. + /// + /// Selecting the row the item already occupies is a no-op. public func moveItem(_ id: ToolbarItemID, toRow rowIndex: Int) { + guard displayRows.indices.contains(rowIndex), + !displayRows[rowIndex].contains(id) + else { return } apply(reducer.move(id, toRow: rowIndex, in: currentLayout)) persistAndNotify() } From 2d9f701c954eda264dfda57adfaff79d34ce3483 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 18:49:53 -0700 Subject: [PATCH 07/16] Stabilize shell integration path test --- tests/test_claude_wrapper_user_binary_resolution.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_claude_wrapper_user_binary_resolution.py b/tests/test_claude_wrapper_user_binary_resolution.py index 170c60e21081..871e30c75026 100644 --- a/tests/test_claude_wrapper_user_binary_resolution.py +++ b/tests/test_claude_wrapper_user_binary_resolution.py @@ -191,7 +191,9 @@ def test_shell_integration_preserves_empty_path_components(failures: list[str]) base_env["CMUX_SHELL_INTEGRATION_DIR"] = str(SHELL_INTEGRATION_DIR) base_env["CMUX_SURFACE_ID"] = surface_id base_env["TMPDIR"] = str(tmpdir) - base_env["PATH"] = f":{first}::{shim_root}:{last}:" + test_path = f":{first}::{shim_root}:{last}:" + base_env["PATH"] = "/usr/bin:/bin" + base_env["CMUX_TEST_INITIAL_PATH"] = test_path base_env.pop("CMUX_SOCKET_PATH", None) base_env.pop("GHOSTTY_BIN_DIR", None) @@ -201,13 +203,13 @@ def test_shell_integration_preserves_empty_path_components(failures: list[str]) "--noprofile", "--norc", "-c", - 'source "$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; printf "%s\\n" "$PATH"', + 'PATH="$CMUX_TEST_INITIAL_PATH"; export PATH; source "$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; printf "%s\\n" "$PATH"', ], [ "/bin/zsh", "-f", "-c", - 'source "$CMUX_SHELL_INTEGRATION_DIR/cmux-zsh-integration.zsh"; printf "%s\\n" "$PATH"', + 'PATH="$CMUX_TEST_INITIAL_PATH"; export PATH; source "$CMUX_SHELL_INTEGRATION_DIR/cmux-zsh-integration.zsh"; printf "%s\\n" "$PATH"', ], ] for argv in shell_commands: From ded38f830df6bdb59a07cbf36558b14dc6b066c3 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 17:10:12 -0700 Subject: [PATCH 08/16] Document intentional within-row reorder for Shared Shortcuts The agent-chat Shared Shortcuts flat list reorders scoped items strictly within their current terminal rows. A flat reorder cannot move one item across a fixed-length row boundary without changing row lengths or cascading another item into a different row (the silent row scramble previously fixed for this path), so cross-row moves are intentionally routed through the per-item row picker. Add a call-site comment so the deliberate snap-back behavior is clear to future readers. Co-Authored-By: Claude Opus 4.8 --- .../CmuxMobileShellUI/TerminalShortcutsSettingsView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift index 9a82c635189d..46a026dc9a4e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift @@ -221,6 +221,14 @@ struct TerminalShortcutsSettingsView: View { } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int) { + // Agent-chat ("Shared Shortcuts") scope only: a single flat list whose items + // may be spread across several terminal rows. Reorder strictly *within* each + // item's current row (`limitedTo:`) so a drag never silently reshuffles the + // terminal row layout. A flat reorder cannot move one item across a + // fixed-length row boundary without either changing row lengths or cascading + // another item into a different row — the silent row scramble fixed for this + // path. Cross-row moves are intentionally routed through the Terminal + // Shortcuts per-item row picker (`moveItem(_:toRow:)`) instead. let visibleIDs = displayedItems.map(\.id) let visibleSet = Set(visibleIDs) var reorderedVisibleIDs = visibleIDs From 9af23e5354c27196fd891931a8f2881acb711b36 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 2 Jul 2026 02:18:02 -0700 Subject: [PATCH 09/16] Document intentional top-anchored toolbar row growth autoreview flagged that increasing the iOS toolbar row count appends empty rows at the bottom, leaving existing shortcuts on the upper row. This is intentional. Rows are numbered top-to-bottom ("Row 1"..."Row N") in both the settings UI (Row sections + per-item "Move to Row" picker) and the toolbar (Row 1 at top, Row N nearest the keyboard with the fixed HIDE/customize controls). Growth appends new empty rows AFTER the existing ones so each existing row keeps its number and position rather than being renumbered on every count change; users populate the new rows explicitly via the row picker and within-row drag. Prepending / bottom-anchoring instead would push the user's existing shortcuts to a higher-numbered row on every increase, contradicting the stable "Row 1...N" numbering, and would break the behavior pinned by TerminalAccessoryLayoutReducerTests (setRowCount grow -> [[...],[],[]]). Doc-only change: expands the setRowCount doc comment and adds an inline note at the append site. No behavior, API, or test change. --- .../TerminalAccessoryLayoutReducer.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift index b1d358f045bb..06c899674ad3 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift @@ -339,6 +339,18 @@ public struct TerminalAccessoryLayoutReducer: Sendable /// Reducing the count preserves every action by merging overflow rows into /// the last retained row. Increasing the count appends empty rows. /// + /// Rows are numbered top-to-bottom: "Row 1"…"Row N" in the settings UI, and + /// in the toolbar "Row 1" renders at the top down to "Row N" nearest the + /// keyboard (the last row also carries the fixed HIDE/customize controls). + /// Growth deliberately appends the new empty rows *after* the existing ones + /// so each existing row keeps its number and position instead of being + /// renumbered/shifted on every count change; the added rows start empty and + /// the user fills them via the per-item "Move to Row" picker (`move(_:toRow:in:)`) + /// and within-row drag. Prepending/bottom-anchoring instead would push the + /// user's existing shortcuts to a higher-numbered row on every increase, + /// contradicting the stable "Row 1…N" numbering. This is intentional and + /// pinned by `TerminalAccessoryLayoutReducerTests`. + /// /// - Parameters: /// - rowCount: The requested row count. Values below 1 are clamped to 1. /// - layout: The current layout. @@ -351,6 +363,8 @@ public struct TerminalAccessoryLayoutReducer: Sendable let mergedTail = rows.dropFirst(desiredRowCount - 1).flatMap { $0 } rows = Array(keptPrefix) + [mergedTail] } else if rows.count < desiredRowCount { + // Append (not prepend) so existing rows keep their "Row 1…k" numbers; + // see the doc comment above for why growth is intentionally top-anchored. rows.append(contentsOf: Array(repeating: [], count: desiredRowCount - rows.count)) } return Layout(rows: rows, enabled: layout.enabled) From b250a775945f0894a1661d5061749c3d79f40f05 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 13:45:17 -0700 Subject: [PATCH 10/16] Address iOS toolbar row review feedback --- .github/swift-file-length-budget.tsv | 2 +- .../TerminalShortcutsSettingsView.swift | 10 ++++++++++ .../CmuxMobileTerminal/GhosttySurfaceView.swift | 11 +++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 4667ecc27040..0f1581f43d09 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -24,7 +24,7 @@ 4483 Sources/cmuxApp.swift 4482 Sources/Panels/FilePreviewPanel.swift 4367 cmuxTests/BrowserPanelTests.swift -4252 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +4251 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 4121 Sources/BrowserWindowPortal.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift index 46a026dc9a4e..c1cc5438cf6a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift @@ -237,6 +237,16 @@ struct TerminalShortcutsSettingsView: View { } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { + // SwiftUI supplies offsets in displayed-row coordinates, while the + // configuration mutates raw row coordinates. The terminal scope is expected + // to be unfiltered; if that ever changes, this path must translate indices. + guard configuration.displayRows.indices.contains(rowIndex), + displayedItemRows.indices.contains(rowIndex), + displayedItemRows[rowIndex].map(\.id) == configuration.displayRows[rowIndex] + else { + assertionFailure("Terminal row reordering requires unfiltered row indices") + return + } configuration.moveItems(from: offsets, to: destination, inRow: rowIndex) } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 7582e16bc416..5d58fa090a33 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1396,10 +1396,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// the current keyboard + composer state. Hiding the bar releases its reserved /// height so the terminal grid reclaims that space; showing it reserves the /// height again. Idempotent: a no-op when already in the target state. - private func updateDockedToolbarVisibility() { + @discardableResult + private func updateDockedToolbarVisibility() -> Bool { let shouldShow = dockedToolbarShouldBeVisible let reserved: CGFloat = shouldShow ? persistentToolbarHeight : 0 - guard dockedToolbar?.isHidden != !shouldShow || reservedToolbarHeight != reserved else { return } + guard dockedToolbar?.isHidden != !shouldShow || reservedToolbarHeight != reserved else { return false } dockedToolbar?.isHidden = !shouldShow // The composer band rides with the toolbar: hide it when the chrome is // suppressed, show it again when the chrome returns and a field is mounted. @@ -1410,15 +1411,13 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { layoutRenderedTerminalForCurrentViewport() setNeedsGeometrySync() setNeedsLayout() + return true } @objc private func handleAccessoryConfigurationChanged() { - updateDockedToolbarVisibility() + guard updateDockedToolbarVisibility() else { return } layoutBottomDock() - layoutRenderedTerminalForCurrentViewport() layoutZoomOverlay() - setNeedsGeometrySync() - setNeedsLayout() } /// Temporarily hide (or re-show) the bottom chrome — the always-visible toolbar From 69a1f2273acde82c5ea79a6ed233d2f3a10a239e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 15:20:53 -0700 Subject: [PATCH 11/16] Satisfy toolbar row policy review --- .github/swift-file-length-budget.tsv | 2 +- .../TerminalAccessoryConfiguration.swift | 58 +++++++++---------- .../TerminalInputTextView.swift | 6 +- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 0f1581f43d09..28669b1fb9b2 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -58,7 +58,7 @@ 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1649 cmuxTests/TerminalControllerSocketSecurityTests.swift -1626 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1630 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift index 47072609cb82..4336da85147c 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift @@ -90,9 +90,21 @@ public final class TerminalAccessoryConfiguration { public init(defaults: UserDefaults = .standard) { self.defaults = defaults - let loadedCustoms = Self.loadCustomActions(from: defaults) + let loadedCustoms: [CustomToolbarAction] + if let data = defaults.data(forKey: Self.customDefaultsKey), + let decoded = try? JSONDecoder().decode([CustomToolbarAction].self, from: data) { + loadedCustoms = decoded + } else { + loadedCustoms = [] + } self.customActions = loadedCustoms - self.reducer = Self.makeReducer(customActions: loadedCustoms) + let builtinIDs = TerminalInputAccessoryAction.configurableActions.map(\.itemID) + let customIDs = loadedCustoms.map(\.itemID) + let defaultOrder = TerminalInputAccessoryAction.defaultConfigurableOrder.map(\.itemID) + customIDs + self.reducer = TerminalAccessoryLayoutReducer( + configurable: builtinIDs + customIDs, + defaultOrder: defaultOrder + ) // Resolve the persisted layout across schema generations: // v3 present → authoritative; load as-is (modifiers can stay hidden). @@ -105,11 +117,15 @@ public final class TerminalAccessoryConfiguration { var savedOrder: [ToolbarItemID] = [] let savedEnabled: [ToolbarItemID]? let rowCount: Int - if let v4Rows = Self.loadRows(from: defaults) { + let v4Rows = defaults.array(forKey: Self.rowsDefaultsKey)?.map { row in + guard let storageKeys = row as? [String] else { return [] } + return storageKeys.compactMap(ToolbarItemID.init(storageKey:)) + } + if let v4Rows { savedRows = v4Rows savedEnabled = (defaults.array(forKey: Self.enabledDefaultsKey) as? [String])? .compactMap(ToolbarItemID.init(storageKey:)) - rowCount = Self.clampedRowCount(v4Rows.count) + rowCount = min(max(v4Rows.count, Self.minimumRowCount), Self.maximumRowCount) } else if let v3Order = defaults.array(forKey: Self.legacyV3OrderDefaultsKey) as? [String] { savedRows = nil let order = v3Order.compactMap(ToolbarItemID.init(storageKey:)) @@ -303,7 +319,8 @@ public final class TerminalAccessoryConfiguration { /// Change the number of visible toolbar rows. public func setRowCount(_ rowCount: Int) { - apply(reducer.setRowCount(Self.clampedRowCount(rowCount), in: currentLayout)) + let clampedRowCount = min(max(rowCount, Self.minimumRowCount), Self.maximumRowCount) + apply(reducer.setRowCount(clampedRowCount, in: currentLayout)) persistAndNotify() } @@ -329,7 +346,7 @@ public final class TerminalAccessoryConfiguration { /// Append a new custom action, shown at the end of the configurable region. public func addCustomAction(_ action: CustomToolbarAction) { customActions.append(action) - reducer = Self.makeReducer(customActions: customActions) + reducer = makeReducer() apply(reducer.load( savedRows: displayRows, savedEnabled: Array(enabledSet) + [action.itemID], @@ -343,7 +360,7 @@ public final class TerminalAccessoryConfiguration { public func updateCustomAction(_ action: CustomToolbarAction) { guard let index = customActions.firstIndex(where: { $0.id == action.id }) else { return } customActions[index] = action - reducer = Self.makeReducer(customActions: customActions) + reducer = makeReducer() persistAndNotify() } @@ -351,7 +368,7 @@ public final class TerminalAccessoryConfiguration { public func removeCustomAction(id: UUID) { guard customActions.contains(where: { $0.id == id }) else { return } customActions.removeAll { $0.id == id } - reducer = Self.makeReducer(customActions: customActions) + reducer = makeReducer() apply(reducer.load(savedRows: displayRows, savedEnabled: Array(enabledSet), rowCount: rowCount)) persistAndNotify() } @@ -383,9 +400,7 @@ public final class TerminalAccessoryConfiguration { enabledSet = layout.enabled } - private static func makeReducer( - customActions: [CustomToolbarAction] - ) -> TerminalAccessoryLayoutReducer { + private func makeReducer() -> TerminalAccessoryLayoutReducer { let builtin = TerminalInputAccessoryAction.configurableActions.map(\.itemID) let custom = customActions.map(\.itemID) // The redesigned bar's curated built-in arrangement first, then customs, @@ -394,27 +409,6 @@ public final class TerminalAccessoryConfiguration { return TerminalAccessoryLayoutReducer(configurable: builtin + custom, defaultOrder: defaultOrder) } - private static func loadCustomActions(from defaults: UserDefaults) -> [CustomToolbarAction] { - guard let data = defaults.data(forKey: Self.customDefaultsKey), - let decoded = try? JSONDecoder().decode([CustomToolbarAction].self, from: data) else { - return [] - } - return decoded - } - - private static func loadRows(from defaults: UserDefaults) -> [[ToolbarItemID]]? { - guard let stored = defaults.array(forKey: Self.rowsDefaultsKey) else { return nil } - return stored.map { row in - guard let storageKeys = row as? [String] else { return [] } - return storageKeys.compactMap(ToolbarItemID.init(storageKey:)) - } - } - - /// Clamp a requested toolbar row count to the supported range. - public static func clampedRowCount(_ rowCount: Int) -> Int { - min(max(rowCount, minimumRowCount), maximumRowCount) - } - private func persist() { defaults.set(displayRows.map { row in row.map(\.storageKey) }, forKey: Self.rowsDefaultsKey) defaults.set( diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index f4c44726827d..4d0a566712da 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -269,7 +269,11 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private static let dockedRowSpacing: CGFloat = 4 /// Height of the configurable row stack, excluding bottom padding. static func dockedRowsHeight(rowCount: Int) -> CGFloat { - let rows = CGFloat(TerminalAccessoryConfiguration.clampedRowCount(rowCount)) + let clampedRowCount = min( + max(rowCount, TerminalAccessoryConfiguration.minimumRowCount), + TerminalAccessoryConfiguration.maximumRowCount + ) + let rows = CGFloat(clampedRowCount) return rows * dockedNubSize + max(0, rows - 1) * dockedRowSpacing } /// Height reserved by the grid and the composer host for the docked bar. From b27b3a89ce514415b4a2afeccf8913b9954b1a24 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 16:01:46 -0700 Subject: [PATCH 12/16] Keep shared shortcuts row reordering local --- .../TerminalShortcutsSettingsView.swift | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift index c1cc5438cf6a..f46c81d99391 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift @@ -67,6 +67,23 @@ struct TerminalShortcutsSettingsView: View { } } } + } else if configuration.rowCount > 1 { + ForEach(displayedNonEmptyRowSections) { rowSection in + Section { + ForEach(rowSection.items) { item in + row(for: item, rowIndex: rowSection.index) + } + .onMove { offsets, destination in + moveDisplayedItems(from: offsets, to: destination, inRow: rowSection.index) + } + } header: { + Text(rowTitle(rowSection.index)) + } footer: { + if rowSection.id == displayedNonEmptyRowSections.last?.id { + Text(scope.footer) + } + } + } } else { Section { ForEach(displayedItems) { item in @@ -209,6 +226,10 @@ struct TerminalShortcutsSettingsView: View { } } + private var displayedNonEmptyRowSections: [TerminalShortcutRowSection] { + displayedRowSections.filter { !$0.items.isEmpty } + } + private func rowIndex(for id: ToolbarItemID) -> Int? { configuration.displayRows.firstIndex { row in row.contains(id) } } @@ -221,14 +242,13 @@ struct TerminalShortcutsSettingsView: View { } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int) { - // Agent-chat ("Shared Shortcuts") scope only: a single flat list whose items - // may be spread across several terminal rows. Reorder strictly *within* each - // item's current row (`limitedTo:`) so a drag never silently reshuffles the - // terminal row layout. A flat reorder cannot move one item across a - // fixed-length row boundary without either changing row lengths or cascading - // another item into a different row — the silent row scramble fixed for this - // path. Cross-row moves are intentionally routed through the Terminal - // Shortcuts per-item row picker (`moveItem(_:toRow:)`) instead. + // Agent-chat ("Shared Shortcuts") scope with one configured toolbar row: + // flat order is equivalent to row-local order. Multi-row layouts render + // row sections and route through the row-local overload below. + guard configuration.rowCount <= 1 else { + assertionFailure("Flat shortcut reordering is only valid for one-row layouts") + return + } let visibleIDs = displayedItems.map(\.id) let visibleSet = Set(visibleIDs) var reorderedVisibleIDs = visibleIDs @@ -237,17 +257,28 @@ struct TerminalShortcutsSettingsView: View { } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { - // SwiftUI supplies offsets in displayed-row coordinates, while the - // configuration mutates raw row coordinates. The terminal scope is expected - // to be unfiltered; if that ever changes, this path must translate indices. - guard configuration.displayRows.indices.contains(rowIndex), - displayedItemRows.indices.contains(rowIndex), - displayedItemRows[rowIndex].map(\.id) == configuration.displayRows[rowIndex] - else { - assertionFailure("Terminal row reordering requires unfiltered row indices") + guard displayedItemRows.indices.contains(rowIndex) else { return } + let displayedRowIDs = displayedItemRows[rowIndex].map(\.id) + guard !displayedRowIDs.isEmpty else { return } + + if scope == .terminal { + // SwiftUI supplies offsets in displayed-row coordinates, while the + // configuration mutates raw row coordinates. The terminal scope is + // expected to be unfiltered; if that ever changes, this path must + // translate indices. + guard configuration.displayRows.indices.contains(rowIndex), + displayedRowIDs == configuration.displayRows[rowIndex] + else { + assertionFailure("Terminal row reordering requires unfiltered row indices") + return + } + configuration.moveItems(from: offsets, to: destination, inRow: rowIndex) return } - configuration.moveItems(from: offsets, to: destination, inRow: rowIndex) + + var reorderedIDs = displayedRowIDs + reorderedIDs.move(fromOffsets: offsets, toOffset: destination) + configuration.reorderItems(reorderedIDs, limitedTo: Set(displayedRowIDs)) } } #endif From 4aef6c404f0a4ca64129b18438aa7e37930bb7ee Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 16:35:48 -0700 Subject: [PATCH 13/16] Move toolbar height metrics to value type --- .github/swift-file-length-budget.tsv | 2 +- .../GhosttySurfaceView.swift | 2 +- .../TerminalAccessoryDockMetrics.swift | 24 +++++++++++++ .../TerminalInputTextView.swift | 35 +++++-------------- .../TerminalAccessoryLayoutReducer.swift | 4 +-- 5 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryDockMetrics.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 28669b1fb9b2..d63dfd94da61 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -58,7 +58,7 @@ 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1649 cmuxTests/TerminalControllerSocketSecurityTests.swift -1630 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1611 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 5d58fa090a33..841fd2325206 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1057,7 +1057,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// bottom TUI rows stay visible above it. Locked to the bar's actual configured /// row-stack height so the grid reserves exactly the strip the buttons occupy. private var persistentToolbarHeight: CGFloat { - TerminalInputTextView.dockedButtonRowHeight(rowCount: TerminalAccessoryConfiguration.shared.rowCount) + TerminalAccessoryDockMetrics(rowCount: TerminalAccessoryConfiguration.shared.rowCount).buttonRowHeight } /// The docked accessory bar. Positioned by ``bottomDockFrames()`` with the /// SAME bottom-occupancy math as the grid reservation, so its top is always diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryDockMetrics.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryDockMetrics.swift new file mode 100644 index 000000000000..d73dfec52752 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryDockMetrics.swift @@ -0,0 +1,24 @@ +#if os(iOS) +import CoreGraphics + +struct TerminalAccessoryDockMetrics { + static let nubSize: CGFloat = 28 + static let bottomPadding: CGFloat = 8 + static let rowSpacing: CGFloat = 4 + + let rowCount: Int + + var rowsHeight: CGFloat { + let clampedRowCount = min( + max(rowCount, TerminalAccessoryConfiguration.minimumRowCount), + TerminalAccessoryConfiguration.maximumRowCount + ) + let rows = CGFloat(clampedRowCount) + return rows * Self.nubSize + max(0, rows - 1) * Self.rowSpacing + } + + var buttonRowHeight: CGFloat { + rowsHeight + Self.bottomPadding + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index 4d0a566712da..7ccb8cc16540 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -255,37 +255,18 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// with ``accessoryButtonHeight`` (the floor below which the buttons would clip /// the strip) so the bar — and the reserved grid band above the keyboard — is as /// short as it can be while every control stays a comfortable tap target. - static let dockedNubSize: CGFloat = 28 + static let dockedNubSize: CGFloat = TerminalAccessoryDockMetrics.nubSize /// Breathing room below the control row, between the buttons and the keyboard /// top (or the home indicator when the keyboard is down), so the bar is not /// flush-tight at its bottom while the TOP stays snug to the terminal's last - /// row. It is part of ``dockedButtonRowHeight`` so the grid reservation, the - /// surface frame, and the composer host all reserve the same total band; the + /// row. It is part of the docked button-row height so the grid reservation, + /// the surface frame, and the composer host all reserve the same total band; the /// row stack itself is pinned to the BOTTOM of that band minus this padding /// (see the docked bar's constraints), so the extra space lands below the /// controls. - static let dockedBottomPadding: CGFloat = 8 + static let dockedBottomPadding: CGFloat = TerminalAccessoryDockMetrics.bottomPadding /// Vertical gap between configurable toolbar rows. - private static let dockedRowSpacing: CGFloat = 4 - /// Height of the configurable row stack, excluding bottom padding. - static func dockedRowsHeight(rowCount: Int) -> CGFloat { - let clampedRowCount = min( - max(rowCount, TerminalAccessoryConfiguration.minimumRowCount), - TerminalAccessoryConfiguration.maximumRowCount - ) - let rows = CGFloat(clampedRowCount) - return rows * dockedNubSize + max(0, rows - 1) * dockedRowSpacing - } - /// Height reserved by the grid and the composer host for the docked bar. - /// - /// It is the configured row stack (one to three rows) plus - /// ``dockedBottomPadding`` below it. The row stack is pinned to the BOTTOM of - /// this band (minus the padding): when the surface-hosted container grows - /// taller than the reserved band, the controls stay glued to the keyboard top - /// and only the slack ABOVE them grows. - static func dockedButtonRowHeight(rowCount: Int) -> CGFloat { - dockedRowsHeight(rowCount: rowCount) + dockedBottomPadding - } + private static let dockedRowSpacing: CGFloat = TerminalAccessoryDockMetrics.rowSpacing /// Minimum (not fixed) button width. Text buttons (Tab, Esc, ^C, ^D) size to /// their intrinsic content width and only floor here so they hug their label /// plus the comfortable inset; single-glyph modifiers/icons (⌃ ⌥ ⌘, the arrow @@ -309,7 +290,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { x: 0, y: 0, width: 0, - height: Self.dockedButtonRowHeight(rowCount: TerminalAccessoryConfiguration.shared.rowCount) + height: TerminalAccessoryDockMetrics(rowCount: TerminalAccessoryConfiguration.shared.rowCount).buttonRowHeight ) let backgroundView = UIView() @@ -394,7 +375,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { container.addLayoutGuide(buttonRows) container.addLayoutGuide(bottomButtonRow) let buttonRowsHeightConstraint = buttonRows.heightAnchor.constraint( - equalToConstant: Self.dockedRowsHeight(rowCount: TerminalAccessoryConfiguration.shared.rowCount) + equalToConstant: TerminalAccessoryDockMetrics(rowCount: TerminalAccessoryConfiguration.shared.rowCount).rowsHeight ) NSLayoutConstraint.activate([ @@ -606,7 +587,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } private func updateAccessoryRowsHeight(rowCount: Int) { - accessoryRowsHeightConstraint?.constant = Self.dockedRowsHeight(rowCount: rowCount) + accessoryRowsHeightConstraint?.constant = TerminalAccessoryDockMetrics(rowCount: rowCount).rowsHeight } /// Disarm the active modifier if its bar button is no longer rendered, so a diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift index 06c899674ad3..85ccb0985b2f 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift @@ -237,7 +237,7 @@ public struct TerminalAccessoryLayoutReducer: Sendable } let insertionIndex = destination - movedIndices.filter { $0 < destination }.count order.insert(contentsOf: moved, at: max(0, min(insertionIndex, order.count))) - return Layout(rows: Self.split(order, matchingRowLengthsOf: layout.rows), enabled: layout.enabled) + return Layout(rows: split(order, matchingRowLengthsOf: layout.rows), enabled: layout.enabled) } /// Returns `layout` with a scoped set of identifiers reordered inside their current rows. @@ -375,7 +375,7 @@ public struct TerminalAccessoryLayoutReducer: Sendable Layout(order: defaultOrder, enabled: configurableSet) } - private static func split(_ order: [ID], matchingRowLengthsOf rows: [[ID]]) -> [[ID]] { + private func split(_ order: [ID], matchingRowLengthsOf rows: [[ID]]) -> [[ID]] { guard !rows.isEmpty else { return [order] } var result: [[ID]] = [] var cursor = order.startIndex From d59b9493f4d92f10cc091a74bcd34a1a26847531 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 18:53:50 -0700 Subject: [PATCH 14/16] Regenerate Swift file length budget --- .github/swift-file-length-budget.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7b608f0effd7..adfe1155360d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -133,6 +133,7 @@ 761 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift 754 Sources/TerminalController+ControlWorkspaceContext.swift +754 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift @@ -195,7 +196,6 @@ 586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 586 Sources/JSONCParser.swift 585 Sources/Cloud/VMClient.swift -754 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift 580 Packages/macOS/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift 580 cmuxTests/CLIHookNoResponseTests.swift 577 cmuxTests/AppearanceSettingsTests.swift From 0aee11730db4bdafcb47439d32ede7a5c8607458 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 19:03:02 -0700 Subject: [PATCH 15/16] Fix shared shortcut cross-row reordering --- .../TerminalShortcutsSettingsView.swift | 30 +--------------- .../TerminalAccessoryConfiguration.swift | 11 ++++++ .../TerminalAccessoryLayoutReducer.swift | 35 +++++++++++++++++++ .../TerminalAccessoryLayoutReducerTests.swift | 14 ++++++++ 4 files changed, 61 insertions(+), 29 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift index f46c81d99391..103590480a6a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift @@ -67,23 +67,6 @@ struct TerminalShortcutsSettingsView: View { } } } - } else if configuration.rowCount > 1 { - ForEach(displayedNonEmptyRowSections) { rowSection in - Section { - ForEach(rowSection.items) { item in - row(for: item, rowIndex: rowSection.index) - } - .onMove { offsets, destination in - moveDisplayedItems(from: offsets, to: destination, inRow: rowSection.index) - } - } header: { - Text(rowTitle(rowSection.index)) - } footer: { - if rowSection.id == displayedNonEmptyRowSections.last?.id { - Text(scope.footer) - } - } - } } else { Section { ForEach(displayedItems) { item in @@ -226,10 +209,6 @@ struct TerminalShortcutsSettingsView: View { } } - private var displayedNonEmptyRowSections: [TerminalShortcutRowSection] { - displayedRowSections.filter { !$0.items.isEmpty } - } - private func rowIndex(for id: ToolbarItemID) -> Int? { configuration.displayRows.firstIndex { row in row.contains(id) } } @@ -242,18 +221,11 @@ struct TerminalShortcutsSettingsView: View { } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int) { - // Agent-chat ("Shared Shortcuts") scope with one configured toolbar row: - // flat order is equivalent to row-local order. Multi-row layouts render - // row sections and route through the row-local overload below. - guard configuration.rowCount <= 1 else { - assertionFailure("Flat shortcut reordering is only valid for one-row layouts") - return - } let visibleIDs = displayedItems.map(\.id) let visibleSet = Set(visibleIDs) var reorderedVisibleIDs = visibleIDs reorderedVisibleIDs.move(fromOffsets: offsets, toOffset: destination) - configuration.reorderItems(reorderedVisibleIDs, limitedTo: visibleSet) + configuration.reorderItemsAcrossRows(reorderedVisibleIDs, limitedTo: visibleSet) } private func moveDisplayedItems(from offsets: IndexSet, to destination: Int, inRow rowIndex: Int) { diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift index 4336da85147c..ad314442cbba 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift @@ -343,6 +343,17 @@ public final class TerminalAccessoryConfiguration { persistAndNotify() } + /// Reorder a scoped subset of configurable items across row-major slots. + /// + /// Non-scoped items keep their exact row positions, while scoped items can + /// trade places across the slots occupied by that scope. This keeps flat + /// settings surfaces aligned with flat runtime consumers without moving + /// terminal-only items. + public func reorderItemsAcrossRows(_ orderedIDs: [ToolbarItemID], limitedTo scopedIDs: Set) { + apply(reducer.reorderAcrossRows(orderedIDs, limitedTo: scopedIDs, in: currentLayout)) + persistAndNotify() + } + /// Append a new custom action, shown at the end of the configurable region. public func addCustomAction(_ action: CustomToolbarAction) { customActions.append(action) diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift index 85ccb0985b2f..3670ed553444 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift @@ -287,6 +287,41 @@ public struct TerminalAccessoryLayoutReducer: Sendable return Layout(rows: rows, enabled: layout.enabled) } + /// Returns `layout` with a scoped set of identifiers reordered across row-major slots. + /// + /// Use this when a settings surface renders a flat scoped list while the + /// underlying toolbar may have multiple rows. Identifiers outside `scopedIDs` + /// keep their exact row positions, row lengths stay unchanged, and scoped + /// identifiers can move between the row-major slots occupied by that scope. + /// + /// - Parameters: + /// - orderedIDs: The desired row-major order for the scoped identifiers. + /// - scopedIDs: The identifiers the caller's current surface is allowed to move. + /// - layout: The current layout. + /// - Returns: The updated layout. + public func reorderAcrossRows(_ orderedIDs: [ID], limitedTo scopedIDs: Set, in layout: Layout) -> Layout { + let validScopedIDs = scopedIDs.intersection(configurableSet).intersection(Set(layout.order)) + guard !validScopedIDs.isEmpty else { return layout } + + var seen = Set() + var scopedOrder = orderedIDs.filter { identifier in + validScopedIDs.contains(identifier) && seen.insert(identifier).inserted + } + for identifier in layout.order where validScopedIDs.contains(identifier) && seen.insert(identifier).inserted { + scopedOrder.append(identifier) + } + guard !scopedOrder.isEmpty else { return layout } + + var iterator = scopedOrder.makeIterator() + let rows = layout.rows.map { row in + row.map { identifier in + guard validScopedIDs.contains(identifier) else { return identifier } + return iterator.next() ?? identifier + } + } + return Layout(rows: rows, enabled: layout.enabled) + } + /// Returns `layout` with items moved within one toolbar row. /// /// `offsets`/`destination` follow the SwiftUI `onMove` contract: indices into diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift index 244bd8910f65..96bf838acda0 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift @@ -155,6 +155,20 @@ struct TerminalAccessoryLayoutReducerTests { #expect(moved.enabled == layout.enabled) } + @Test("flat scoped reorder can move scoped items across row slots") + func scopedReorderAcrossRowsPreservesNonScopedSlots() { + let scopedReducer = TerminalAccessoryLayoutReducer(configurable: [0, 1, 2, 3, 4, 5]) + let layout = TerminalAccessoryLayoutReducer.Layout( + rows: [[0, 1, 2], [3, 4, 5]], + enabled: Set([0, 1, 2, 3, 4, 5]) + ) + + let moved = scopedReducer.reorderAcrossRows([5, 3, 0, 2], limitedTo: Set([0, 2, 3, 5]), in: layout) + + #expect(moved.rows == [[5, 1, 3], [0, 4, 2]]) + #expect(moved.enabled == layout.enabled) + } + @Test("moving an item to another row preserves enabled state") func moveItemToRow() { let layout = TerminalAccessoryLayoutReducer.Layout( From 625d33a7c6d38909c40c133aa6df243f888e09b3 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 20:26:45 -0700 Subject: [PATCH 16/16] Refresh Swift file length budget --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index adfe1155360d..c13072fd2573 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -130,10 +130,10 @@ 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift 762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift -761 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift 754 Sources/TerminalController+ControlWorkspaceContext.swift 754 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift +753 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift @@ -145,9 +145,9 @@ 706 CLI/CMUXCLI+Config.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift +697 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 696 cmuxTests/KeyboardShortcutContextTests.swift 696 cmuxTests/UpdatePillReleaseVisibilityTests.swift -694 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 691 Sources/NotificationSoundSettings.swift 691 cmuxTests/TaskManagerResourcesTests.swift 690 cmuxTests/SessionIndexViewTests.swift