Skip to content

iOS: data-driven terminal toolbar with custom actions + customize button - #5510

Merged
lawrencecchen merged 2 commits into
mainfrom
feat-ios-toolbar-custom-actions
Jun 6, 2026
Merged

lawrencecchen merged 2 commits into
mainfrom
feat-ios-toolbar-custom-actions

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Make the iOS terminal keyboard toolbar fully data-driven: built-in shortcuts and new user-defined custom actions are ordered, shown/hidden, and reordered together.
  • Add a "customize" button at the end of the toolbar that opens the shortcuts editor directly (previously only reachable via Settings → Terminal Shortcuts).
  • Custom actions send literal text (e.g. claude --dangerously-skip-permissions), with an optional "Run after typing" that appends Return. The editor supports add / edit / delete; the model also supports key-combo actions (encoded via the existing TerminalKeyEncoder) for a follow-up editor surface.
  • One-time, lossless migration of the persisted layout from the v1 [Int] schema to a unified ToolbarItemID schema, preserving every existing user's order and hidden set.

Design

  • New pure, host-testable types in CmuxMobileTerminalKit: ToolbarItemID (.builtin/.custom), ToolbarActionPayload, CustomToolbarAction (with byte output), ToolbarLayoutMigration, and a generic TerminalAccessoryLayoutReducer<ID> (the existing [Int] reducer tests still pass unchanged).
  • TerminalAccessoryConfiguration (CmuxMobileTerminal) now persists order/enabled as ToolbarItemID storage keys + custom actions as JSON, and projects ResolvedToolbarItems for the bar builder and the editor.
  • Button identity moved from a fragile Int tag to an AccessoryActionButton that carries its resolved item, so custom actions never collide with built-in enum raw values. The built-in modifier/zoom/armed machinery is unchanged.
  • The editor lives in CmuxMobileShellUI and is presented from the surface's Coordinator (the terminal package fires a delegate callback; the UI package owns the editor), keeping the package layering correct.

Testing

  • swift test --package-path Packages/CmuxMobileTerminalKit — 68 tests pass, including new coverage for ToolbarItemID round-trip, the generic reducer over mixed built-in/custom ids, v1→v2 migration preserving order/enabled, and CustomToolbarAction.output (text normalization + key-combo encoding).
  • iOS simulator build (ios/scripts/reload.sh --tag tbar).
  • Localization: en + ja added to ios/cmux/Resources/Localizable.xcstrings for every new string.

Issues

  • Part of the iOS feature set: configurable toolbar (this PR), workspace rename/pin, multi-Mac switcher (follow-up PRs).

Merge order

First of three stacked iOS PRs. The only cross-PR overlap is ios/cmux/Resources/Localizable.xcstrings (each adds keys). Merge #5510 → #5512 → #5513; the later PRs rebase on main.


Note

Medium Risk
Touches the live terminal input path (bytes sent on tap) and UserDefaults migration; mistakes could alter toolbar layout or inject unexpected input, but scope is localized and covered by kit tests.

Overview
The iOS terminal keyboard toolbar becomes data-driven: built-in shortcuts and new user-defined custom actions share one order, visibility, and reorder model via ToolbarItemID and a generic TerminalAccessoryLayoutReducer. Custom actions send literal text (optional auto-Return) or key combos; persistence moves to a v2 UserDefaults schema with a one-time v1→v2 migration that preserves existing order and hidden state.

UI: A trailing Customize control on the bar fires a delegate callback; GhosttySurfaceRepresentable presents TerminalShortcutsSettingsView, which now supports add/edit/delete custom actions (CustomToolbarActionEditorView). Toolbar buttons use AccessoryActionButton with ResolvedToolbarItem instead of Int tags. Zoom controls move to the trailing pinned region; modifier/armed behavior is unchanged.

Reviewed by Cursor Bugbot for commit 77cc9c4. Bugbot is set up for automated code reviews on this repo. Configure here.

Make the iOS keyboard accessory bar fully data-driven: built-in shortcuts
and user-defined custom actions are ordered, shown/hidden, and reordered
together. Adds a "customize" button at the end of the bar that opens the
shortcuts editor directly, and an add/edit/delete flow for custom actions
that type literal text (e.g. a Claude/Codex launcher), with an optional
"Run after typing" that appends Return.

New pure CmuxMobileTerminalKit types (ToolbarItemID, ToolbarActionPayload,
CustomToolbarAction, ToolbarLayoutMigration) plus a generic
TerminalAccessoryLayoutReducer<ID> own the logic and migration, unit-tested
via swift test. TerminalAccessoryConfiguration persists the unified
ToolbarItemID layout; a one-time v1->v2 migration preserves every existing
user's order and hidden set. Button identity moves to AccessoryActionButton
so custom UUIDs never collide with built-in enum raw values; the
modifier/zoom/armed machinery is unchanged. The editor lives in
CmuxMobileShellUI and is presented from the surface Coordinator to keep the
package layering correct. en+ja localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 6, 2026 3:36pm
cmux-staging Building Building Preview, Comment Jun 6, 2026 3:36pm

@coderabbitai

coderabbitai Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

Complex PR? Review this PR in Change Stack to move by importance, not file order.

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces user-configurable custom terminal toolbar actions by adding a unified toolbar item identification system, refactoring the accessory configuration from enum-based to ID-based storage with backward-compatible migration, integrating action editing into SwiftUI, and wiring the settings UI into existing UIKit terminal views.

Changes

Custom Toolbar Actions Feature

Layer / File(s) Summary
Toolbar item identifiers and action data model
Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift, ToolbarActionPayload.swift, CustomToolbarAction.swift, ToolbarSpecialKey.swift, TerminalKeyModifier+Codable.swift, ToolbarLayoutMigration.swift
New ToolbarItemID enum unifies built-in (by Int rawValue) and custom (by UUID) items. CustomToolbarAction struct models user-defined actions with editable title, optional symbol, and ToolbarActionPayload (text or keyCombo). ToolbarLayoutMigration converts v1 integer arrays to v2 ToolbarItemID storage while preserving order and nil/empty distinctions. Supporting types add Codable for persistence.
Layout reducer generalization for toolbar items
Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift
TerminalAccessoryLayoutReducer is refactored from Int-based identifiers to generic ID: Hashable & Sendable, enabling a single layout system for both built-in shortcuts and custom actions. Public APIs and internal normalization logic updated to work with opaque identifiers.
Terminal accessory configuration refactor and persistence
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift
Configuration migrates from enum-only state to ToolbarItemID and CustomToolbarAction storage with v2 persistence keys. Init detects v1 legacy storage and auto-migrates via ToolbarLayoutMigration (one-time). Public read APIs replace enabledActions with displayItems and enabledItems (resolved toolbar items). Mutations operate on ToolbarItemID and add full custom-action lifecycle: addCustomAction, updateCustomAction, removeCustomAction. Internal logic resolves items and rebuilds reducer when custom actions change.
Resolved toolbar item and type-safe button wrapper
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/ResolvedToolbarItem.swift, AccessoryActionButton.swift
ResolvedToolbarItem enum unifies built-in vs custom variants with computed id, isCustom, customAction, and settingsDisplayName properties. AccessoryActionButton UIButton subclass carries ResolvedToolbarItem for type-safe button dispatch.
Terminal delegate and action enhancements
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift, TerminalInputAccessoryAction+ItemID.swift
GhosttySurfaceViewDelegate gains ghosttySurfaceViewDidRequestToolbarSettings(_:) callback with default no-op. TerminalInputAccessoryAction conforms to Sendable and exposes itemID property (.builtin(rawValue)) to bridge built-in actions into ToolbarItemID space.
Terminal input text view refactoring for custom actions
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
Refactored from tag-based UIButton to typed AccessoryActionButton handling. populateAccessoryActions() now iterates enabledItems, creates buttons via typed factories, and appends customize button. applyModifierPresentation() and refreshAccessoryButtonStyles() updated to work with typed buttons. Added handleCustomAction(_:) dispatch and onOpenToolbarSettings callback. New factories for custom buttons and customize button with proper wiring.
SwiftUI editor and settings views
Packages/CmuxMobileShellUI/Package.swift, CustomToolbarActionEditorView.swift, GhosttySurfaceRepresentable.swift, TerminalShortcutsSettingsView.swift
CustomToolbarActionEditorView provides form UI (title, command, run-after-typing toggle) with validation and save/seed helpers. GhosttySurfaceRepresentable.Coordinator presents editor via UIHostingController using responder-chain walking to find top-most controller. TerminalShortcutsSettingsView refactored to iterate displayItems, render via row(for:) helper, support drag-reorder via moveItems, and manage add/edit/delete sheets. Package.swift adds CmuxMobileTerminalKit dependency.
Comprehensive tests and localized strings
Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift, ios/cmux/Resources/Localizable.xcstrings
New test suite verifies ToolbarItemID storage-key round-trips, TerminalAccessoryLayoutReducer<ToolbarItemID> ordering and deletion, ToolbarLayoutMigration semantics, and CustomToolbarAction output generation (newline normalization, key-combo encoding, Codable round-trips). Localized strings added for delete/edit/save, shortcuts UI, TestFlight links, toolbar editor labels, and customize button.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The PR spans three packages and multiple architectural layers with significant logic density: generalized reducer, v1→v2 migration, configuration refactoring, UIKit/SwiftUI integration, and new model types. Changes are heterogeneous across model design, storage logic, type-safe button dispatch, and UI workflows, requiring separate reasoning for each layer despite consistent patterns.

Poem

🐰 A toolbar grows with custom flair,
From humble buttons now to everywhere,
Swift models dance with IDs so bright,
From v1 to v2—migration done right,
Let users shape their terminal's might!


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error O(n) linear scan in resolve() called from SwiftUI body path. displayItems/enabledItems properties scan all customActions with .first for each item displayed. Convert customActions to Dictionary<UUID, CustomToolbarAction> for O(1) lookups instead of O(n) .first scan in resolve().
Docstring Coverage ⚠️ Warning Docstring coverage is 32.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: making the terminal toolbar data-driven and adding custom actions with a customize button.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PR correctly implements Swift 6 actor isolation: pure Sendable value types, @MainActor UI-bound TerminalAccessoryConfiguration, proper UIKit scoping, no background context access to UI stores.
Cmux Swift Blocking Runtime ✅ Passed PR introduces no new blocking primitives. All blocking code found is pre-existing with documented carve-outs; new files use only non-blocking patterns.
Cmux No Hacky Sleeps ✅ Passed No hacky sleeps, timers, polling, or wall-clock waits introduced in PR changes. All timing-related comments reference existing code design patterns that already conform to the no-sleep rule.
Cmux Swift Concurrency ✅ Passed Fire-and-forget Tasks appear only in UIKit delegate callbacks (allowed SwiftUI boundary). New types avoid DispatchQueue, Combine, and completion handlers per concurrency modernization guidelines.
Cmux Swift @Concurrent ✅ Passed PR follows concurrent annotation rules: no invalid @concurrent, lightweight operations stay on @MainActor, all types properly conform to Sendable.
Cmux Swift File And Package Boundaries ✅ Passed All new files are under 400 lines, domain logic properly isolated in CmuxMobileTerminalKit with unit tests, no mixed responsibilities, and appropriate package boundaries respected throughout.
Cmux Swift Logging ✅ Passed All 15 changed Swift files contain no forbidden logging (print, debugPrint, dump, NSLog). No violations of logging rules found in production code.
Cmux User-Facing Error Privacy ✅ Passed All user-facing strings are properly localized and contain no vendor names, credentials, tokens, sensitive implementation details, or raw error messages. No privacy violations detected.
Cmux Full Internationalization ✅ Passed All new user-facing Swift UI text uses L10n.string() with localization keys; all 22 new string catalog keys have complete en and ja translations.
Cmux Swiftui State Layout ✅ Passed New SwiftUI views use @State for local form state, List iterates immutable snapshots, no @Published/@StateObject, no render-time mutations, no problematic GeometryReader.
Cmux Architecture Rethink ✅ Passed Data-driven toolbar with single state owner, notification-driven rebuilds, clean presentation bridging, and test coverage. No timing repairs, split lifecycle, or extra state owners.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds iOS-only views presented as sheet modals via UIHostingController. No NSWindow, NSPanel, NSWindowController, or SwiftUI Window/WindowGroup. Sheets are allowed.
Cmux Source Artifacts ✅ Passed All 19 files added are legitimate Swift sources, tests, Package manifests, or localization catalogs—no artifacts, logs, caches, build output, or hidden scratch directories detected.
Description check ✅ Passed The pull request description comprehensively covers all required template sections with clear summary, testing approach, and a detailed checklist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-toolbar-custom-actions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes the iOS terminal keyboard toolbar fully data-driven by introducing a unified ToolbarItemID schema (.builtin / .custom) and a generic TerminalAccessoryLayoutReducer<ID> that orders, shows, and hides both shipped shortcuts and user-defined custom actions together. It also adds a "customize" button pinned at the end of the bar, a CustomToolbarActionEditorView sheet for create/edit/delete, and a one-time lossless v1→v2 UserDefaults migration.

  • New pure types in CmuxMobileTerminalKit (ToolbarItemID, ToolbarActionPayload, CustomToolbarAction, ToolbarLayoutMigration) are Sendable, Codable, and covered by 68 unit tests including migration and round-trip cases.
  • TerminalAccessoryConfiguration expanded to persist the v2 layout (order/enabled as storageKey strings + custom actions as JSON); addCustomAction, updateCustomAction, and removeCustomAction all correctly rebuild the reducer and persist atomically. The try? on JSON encoding — already noted in a previous review thread — remains the one unresolved risk.
  • TerminalInputTextView button identity migrated from lossy Int tag to AccessoryActionButton carrying ResolvedToolbarItem; modifier-styling and tap-dispatch loops now type-match on AccessoryActionButton so the plain "customize" UIButton is naturally skipped. All new user-facing strings carry both en and ja translations.

Confidence Score: 5/5

Safe to merge. The data-driven toolbar refactor is well-isolated, the v1→v2 migration is lossless, and the new persistence/mutation paths are consistent and covered by 68 passing tests.

All changed paths — migration, add/edit/delete custom actions, toolbar rebuild, modifier-key arming, and the customize-button presentation flow — behave correctly. The one open item (silent JSON-encode failure in persist()) was raised in a previous review thread and is unchanged here. No new logic defects were introduced.

No files require special attention. TerminalAccessoryConfiguration.swift carries the pre-existing try? risk already flagged in an earlier review.

Important Files Changed

Filename Overview
Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift New type unifying built-in (rawValue) and custom (UUID) toolbar identifiers behind a flat storageKey string. Parsing and round-trip logic are clean and well-tested.
Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift New Codable/Sendable value type for user-defined bar buttons; output property normalises \n→\r and delegates key-combo encoding to TerminalKeyEncoder. Clean and testable.
Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift Generified from TerminalAccessoryLayoutReducer (Int) to TerminalAccessoryLayoutReducer<ID: Hashable & Sendable>; existing tests pass unchanged, new tests cover mixed built-in/custom layouts.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift Expanded from a thin Int-keyed shell to the full v2 persistence layer; v1 migration, addCustomAction, updateCustomAction, removeCustomAction are logically sound. persist() silently swallows JSONEncoder failures (pre-existing review thread), which is the one unresolved risk.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift Bar builder, modifier styling, and tap dispatch all migrated from Int-tag UIButton to AccessoryActionButton carrying ResolvedToolbarItem. The customize button's hardcoded UIColor(white:0.7) tint does not adapt to light mode.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CustomToolbarActionEditorView.swift New SwiftUI create/edit sheet for custom actions; seed()/save() correctly round-trip text+runAfterTyping state, isValid guards the Save button, all strings localized in en+ja.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift Coordinator implements the new delegate method; presentingController(for:) walks the responder chain and climbs presentedViewController to find the correct presenter. Logic is correct.
ios/cmux/Resources/Localizable.xcstrings All new keys (terminal.input_accessory.customize, mobile.toolbar.editor., mobile.shortcuts., mobile.common.{delete,edit}) carry both en and ja translations; localization is complete.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[TerminalAccessoryConfiguration.shared] -->|displayItems / enabledItems| B[ResolvedToolbarItem]
    B --> B1[.builtin TerminalInputAccessoryAction]
    B --> B2[.custom CustomToolbarAction]
    A -->|addCustomAction / updateCustomAction / removeCustomAction / resetToDefaults| C[TerminalAccessoryLayoutReducer&lt;ToolbarItemID&gt;]
    C -->|.load / .setEnabled / .move / .defaultLayout| D[Layout order + enabled]
    D --> A
    A -->|persist| E[(UserDefaults v2 order / enabled / custom JSON)]
    A -->|didChangeNotification| F[TerminalInputTextView]
    F -->|enabledItems| G[AccessoryActionButton carries ResolvedToolbarItem]
    G -->|tap .builtin| H[handleAccessoryAction]
    G -->|tap .custom| I[handleCustomAction → onEscapeSequence]
    F -->|tap customize button| J[onOpenToolbarSettings]
    J --> K[GhosttySurfaceView delegate]
    K --> L[GhosttySurfaceRepresentable.Coordinator]
    L -->|UIHostingController| M[TerminalShortcutsSettingsView]
    M -->|sheet| N[CustomToolbarActionEditorView]
Loading

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +115 to +119
@Test("empty text payload produces no output")
func emptyText() {
#expect(CustomToolbarAction(title: "x", payload: .text("")).output == nil)
#expect(CustomToolbarAction(title: "x", payload: .text("\n")).output == Data("\r".utf8))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The test name says "empty text payload produces no output", but the second assertion confirms that " " does produce output (Data(" ".utf8)). A lone newline is not empty once it normalises to ; the test name and second expectation are in direct contradiction, which makes it easy to misread the intended contract for the " " case. Splitting into two focused tests clarifies intent.

Suggested change
@Test("empty text payload produces no output")
func emptyText() {
#expect(CustomToolbarAction(title: "x", payload: .text("")).output == nil)
#expect(CustomToolbarAction(title: "x", payload: .text("\n")).output == Data("\r".utf8))
}
@Test("empty string payload produces no output")
func emptyStringProducesNil() {
#expect(CustomToolbarAction(title: "x", payload: .text("")).output == nil)
}
@Test("lone newline payload normalises to carriage return")
func loneNewlineNormalisesToCR() {
#expect(CustomToolbarAction(title: "x", payload: .text("\n")).output == Data("\r".utf8))
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift`:
- Around line 95-159: Public API symbols in TerminalAccessoryConfiguration are
missing Swift-DocC triple-slash comments; add concise DocC comments for each
public symbol: displayItems, enabledItems, isEnabled(_:), setEnabled(_:_:),
moveItems(from:to:), addCustomAction(_:), updateCustomAction(_:),
removeCustomAction(id:), and resetToDefaults(). For each, add a one-sentence
summary using ///, and where applicable include /// - Parameters: and /// -
Returns: entries (e.g., isEnabled returns Bool,
setEnabled/moveItems/add/update/remove describe parameters,
displayItems/enabledItems describe contents); follow the existing file’s comment
style and wording pattern for consistency.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputAccessoryAction`+ItemID.swift:
- Line 6: TerminalInputAccessoryAction currently uses implicit Int raw values
which are persisted via ToolbarItemID.builtin(rawValue) -> "builtin.<rawValue>"
and will break when cases are reordered/inserted; make
TerminalInputAccessoryAction an Int-backed enum with explicit, stable rawValue
assignments for every case (and avoid reusing values), so itemID (the var
itemID: ToolbarItemID { .builtin(rawValue) }) continues to return the same
storageKey across versions; add a short comment on each case noting that the
numeric value is stable and reserved to prevent accidental reordering or reuse.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cbcf423f-0d2f-4a01-a495-a68b8ce90540

📥 Commits

Reviewing files that changed from the base of the PR and between b2f0ce0 and 6bc1067.

📒 Files selected for processing (19)
  • Packages/CmuxMobileShellUI/Package.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CustomToolbarActionEditorView.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/ResolvedToolbarItem.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputAccessoryAction+ItemID.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier+Codable.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSpecialKey.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift
  • Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarLayoutMigration.swift
  • Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift
  • ios/cmux/Resources/Localizable.xcstrings

Comment on lines +95 to 159
public var displayItems: [ResolvedToolbarItem] {
displayOrder.compactMap(resolve)
}

/// Snapshot of the live state in the reducer's raw-identifier vocabulary.
private var currentLayout: TerminalAccessoryLayoutReducer.Layout {
TerminalAccessoryLayoutReducer.Layout(
order: displayOrder.map(\.rawValue),
enabled: Set(enabledSet.map(\.rawValue))
)
/// The shown items in display order — exactly what the toolbar's configurable
/// region renders, after the pinned modifier/zoom buttons.
public var enabledItems: [ResolvedToolbarItem] {
displayOrder.filter { enabledSet.contains($0) }.compactMap(resolve)
}

/// Project a reducer layout back onto the `@Observable` stored properties.
private func apply(_ layout: TerminalAccessoryLayoutReducer.Layout) {
displayOrder = layout.order.compactMap(TerminalInputAccessoryAction.init(rawValue:))
enabledSet = Set(layout.enabled.compactMap(TerminalInputAccessoryAction.init(rawValue:)))
/// Whether `id` is currently shown on the bar.
public func isEnabled(_ id: ToolbarItemID) -> Bool {
enabledSet.contains(id)
}

/// Show or hide `action`. No-op for non-configurable actions.
public func setEnabled(_ action: TerminalInputAccessoryAction, _ isEnabled: Bool) {
guard action.isUserConfigurable else { return }
apply(reducer.setEnabled(action.rawValue, isEnabled, in: currentLayout))
// MARK: - Mutations

/// Show or hide the item identified by `id`.
public func setEnabled(_ id: ToolbarItemID, _ isEnabled: Bool) {
apply(reducer.setEnabled(id, isEnabled, in: currentLayout))
persistAndNotify()
}

/// Reorder the configurable actions. `offsets`/`destination` are indices
/// into ``displayOrder`` (the SwiftUI `onMove` contract).
public func moveActions(from offsets: IndexSet, to destination: Int) {
/// Reorder the configurable items. `offsets`/`destination` are indices into
/// ``displayOrder`` (the SwiftUI `onMove` contract).
public func moveItems(from offsets: IndexSet, to destination: Int) {
apply(reducer.move(from: offsets, to: destination, in: currentLayout))
persistAndNotify()
}

/// Restore the default order (enum order) with every shortcut shown.
/// 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)
apply(reducer.load(
savedOrder: displayOrder,
savedEnabled: Array(enabledSet) + [action.itemID]
))
persistAndNotify()
}

/// Replace an existing custom action in place (matched by ``CustomToolbarAction/id``).
/// Its position and shown/hidden state are preserved.
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)
persistAndNotify()
}

/// Remove a custom action by id. It drops from the order and shown set.
public func removeCustomAction(id: UUID) {
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)))
persistAndNotify()
}

/// Restore the default arrangement (canonical order, every item shown).
/// Custom actions are kept (appended after the built-ins), not deleted.
public func resetToDefaults() {
apply(reducer.defaultLayout())
persistAndNotify()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Public method documentation is incomplete.

Several public methods lack Swift-DocC triple-slash comments:

  • displayItems (line 95)
  • enabledItems (line 101)
  • isEnabled(_:) (line 106)
  • setEnabled(_:_:) (line 113)
  • moveItems(from:to:) (line 120)
  • addCustomAction(_:) (line 126)
  • updateCustomAction(_:) (line 136)
  • removeCustomAction(id:) (line 145)
  • resetToDefaults() (line 156)

Based on coding guidelines, every public symbol in packages under Packages/ must be documented with a Swift-DocC triple-slash comment at the time of writing. Add doc comments for each public property and method following the established pattern (one-sentence summary, parameter/returns callouts where applicable).

📝 Proposed documentation template
+    /// Every configurable item in display order (regardless of shown/hidden),
+    /// resolved to its built-in action or custom action. This is what the
+    /// settings editor lists.
     public var displayItems: [ResolvedToolbarItem] {

+    /// The shown items in display order — exactly what the toolbar's configurable
+    /// region renders, after the pinned modifier/zoom buttons.
     public var enabledItems: [ResolvedToolbarItem] {

+    /// Whether `id` is currently shown on the bar.
+    /// - Parameter id: The toolbar item identifier.
+    /// - Returns: `true` if the item is shown, `false` otherwise.
     public func isEnabled(_ id: ToolbarItemID) -> Bool {

+    /// Show or hide the item identified by `id`.
+    /// - Parameters:
+    ///   - id: The toolbar item identifier.
+    ///   - isEnabled: `true` to show, `false` to hide.
     public func setEnabled(_ id: ToolbarItemID, _ isEnabled: Bool) {

+    /// Reorder the configurable items.
+    /// - Parameters:
+    ///   - offsets: The indices being moved (from ``displayOrder``).
+    ///   - destination: The insertion index.
     public func moveItems(from offsets: IndexSet, to destination: Int) {

+    /// Append a new custom action, shown at the end of the configurable region.
+    /// - Parameter action: The custom action to add.
     public func addCustomAction(_ action: CustomToolbarAction) {

+    /// Replace an existing custom action in place (matched by id).
+    /// - Parameter action: The updated action. Its position and shown/hidden state are preserved.
     public func updateCustomAction(_ action: CustomToolbarAction) {

+    /// Remove a custom action by id. It drops from the order and shown set.
+    /// - Parameter id: The UUID of the custom action to remove.
     public func removeCustomAction(id: UUID) {

+    /// Restore the default arrangement (canonical order, every item shown).
+    /// Custom actions are kept (appended after the built-ins), not deleted.
     public func resetToDefaults() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift`
around lines 95 - 159, Public API symbols in TerminalAccessoryConfiguration are
missing Swift-DocC triple-slash comments; add concise DocC comments for each
public symbol: displayItems, enabledItems, isEnabled(_:), setEnabled(_:_:),
moveItems(from:to:), addCustomAction(_:), updateCustomAction(_:),
removeCustomAction(id:), and resetToDefaults(). For each, add a one-sentence
summary using ///, and where applicable include /// - Parameters: and /// -
Returns: entries (e.g., isEnabled returns Bool,
setEnabled/moveItems/add/update/remove describe parameters,
displayItems/enabledItems describe contents); follow the existing file’s comment
style and wording pattern for consistency.

Source: Coding guidelines

public extension TerminalInputAccessoryAction {
/// This built-in action's unified identifier in the configurable region,
/// pairing it with custom actions under one ``ToolbarItemID`` space.
var itemID: ToolbarItemID { .builtin(rawValue) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "itemID persistence mapping:"
rg -n 'var itemID: ToolbarItemID|\.builtin\(rawValue\)' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputAccessoryAction+ItemID.swift

echo
echo "Enum declaration and case list:"
rg -n '^public enum TerminalInputAccessoryAction: Int' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
rg -n '^[[:space:]]*case[[:space:]]+[A-Za-z0-9_]+' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift | sed -n '1,40p'

echo
echo "Explicit case raw-value assignments (expected after hardening):"
rg -n '^[[:space:]]*case[[:space:]]+[A-Za-z0-9_]+[[:space:]]*=' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift

Repository: manaflow-ai/cmux

Length of output: 1494


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "TerminalInputAccessoryAction+ItemID.swift (itemID mapping):"
sed -n '1,120p' Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputAccessoryAction+ItemID.swift

echo
echo "TerminalInputAccessoryAction enum block (full case list / raw values):"
# Show around the enum declaration in GhosttySurfaceView.swift
sed -n '220,320p' Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift

Repository: manaflow-ai/cmux

Length of output: 3621


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '240,310p' Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift

Repository: manaflow-ai/cmux

Length of output: 1869


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Find where ToolbarItemID/builtins are persisted or decoded:"
rg -n --hidden \
  'ToolbarItemID|terminal\.input_accessory|TerminalInputAccessoryAction|\.builtin\(' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal | head -n 200

echo
echo "Find any defaults keys or Codable payloads that store built-in toolbar item raw values:"
rg -n --hidden \
  'quickTerminal|toolbar|enabled|layout|Codable|UserDefaults|`@AppStorage`|defaults' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal | head -n 200

Repository: manaflow-ai/cmux

Length of output: 25072


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "TerminalAccessoryConfiguration persistence keys + how ToolbarItemID/storageKey maps to builtin(rawValue):"
sed -n '1,240p' Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift

echo
echo "ResolvedToolbarItem + ToolbarItemID storageKey/builtin format: (if in separate file, print both quickly)"
rg -n 'struct ToolbarItemID|enum ToolbarItemID|typealias ToolbarItemID|init\(storageKey' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal | head -n 50

# Show the ToolbarItemID definition file by following likely path
# (Use fd to find the file by type name)
FILE=$(fd -a -e swift "ToolbarItemID" Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal --type f | head -n 1 || true)
if [ -n "${FILE}" ]; then
  echo
  echo "ToolbarItemID definition in: $FILE"
  sed -n '1,220p' "$FILE"
else
  echo
  echo "Could not locate ToolbarItemID definition by filename; listing candidate matches:"
  rg -n 'ToolbarItemID' Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal | head -n 80
fi

Repository: manaflow-ai/cmux

Length of output: 13163


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --hidden \
  'ToolbarItemID\.init\(storageKey:|init\(storageKey:|var storageKey:|case let \.builtin|enum ToolbarItemID|struct ToolbarItemID|indirect enum ToolbarItemID' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal | head -n 200

Repository: manaflow-ai/cmux

Length of output: 1369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search for the ToolbarItemID type definition and storageKey formatting:"
rg -n --hidden \
  'enum ToolbarItemID|struct ToolbarItemID|typealias ToolbarItemID|ToolbarItemID\s*\{|storageKey' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal | head -n 200

echo
echo "Search specifically for .builtin(storageKey:) / storageKey constructors:"
rg -n --hidden \
  '\.builtin\(|builtin\(|init\(storageKey:|storageKey:\s*String|var storageKey' \
  Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal | head -n 200

Repository: manaflow-ai/cmux

Length of output: 3062


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "ToolbarItemID definition locations:"
rg -n --hidden --glob='*.swift' \
  'enum ToolbarItemID|struct ToolbarItemID|typealias ToolbarItemID' . | head -n 200

echo
echo "Look for ToolbarItemID.init(storageKey:) / storageKey property across repo:"
rg -n --hidden --glob='*.swift' \
  'init\(storageKey:|storageKey\s*:' . | rg 'ToolbarItemID' | head -n 200

echo
echo "Look for builtin case constructor that takes rawValue:"
rg -n --hidden --glob='*.swift' \
  '\.builtin\(rawValue\)|case builtin|builtin\(' . | rg 'ToolbarItemID' | head -n 200

Repository: manaflow-ai/cmux

Length of output: 3905


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift

echo
echo "--- ToolbarLayoutMigration.swift ---"
sed -n '1,120p' Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarLayoutMigration.swift

echo
echo "--- ResolvedToolbarItem.swift ---"
sed -n '1,120p' Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/ResolvedToolbarItem.swift

Repository: manaflow-ai/cmux

Length of output: 5179


Freeze TerminalInputAccessoryAction raw values used for persisted toolbar IDs.
TerminalInputAccessoryAction.itemID builds ToolbarItemID.builtin(rawValue), whose storageKey persists as "builtin.<rawValue>" in UserDefaults (v2 order/enabled). Because TerminalInputAccessoryAction currently relies on implicit Int raw values, reordering/inserting cases would remap existing "builtin.*" entries on upgrade.

Suggested hardening (explicit stable raw values on the enum)
--- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
+++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
@@
 public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable {
-    case control
-    case alternate
-    case command
-    case shift
-    case zoomOut
-    case zoomIn
-    case escape
-    case tab
-    case upArrow
-    case downArrow
-    case leftArrow
-    case rightArrow
-    case claude
-    case codex
-    case tilde
-    case pipe
-    case dollar
-    case slash
-    case atSign
-    case ctrlC
-    case ctrlD
-    case ctrlZ
-    case ctrlL
-    case home
-    case end
-    case pageUp
-    case pageDown
+    case control = 0
+    case alternate = 1
+    case command = 2
+    case shift = 3
+    case zoomOut = 4
+    case zoomIn = 5
+    case escape = 6
+    case tab = 7
+    case upArrow = 8
+    case downArrow = 9
+    case leftArrow = 10
+    case rightArrow = 11
+    case claude = 12
+    case codex = 13
+    case tilde = 14
+    case pipe = 15
+    case dollar = 16
+    case slash = 17
+    case atSign = 18
+    case ctrlC = 19
+    case ctrlD = 20
+    case ctrlZ = 21
+    case ctrlL = 22
+    case home = 23
+    case end = 24
+    case pageUp = 25
+    case pageDown = 26
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputAccessoryAction`+ItemID.swift
at line 6, TerminalInputAccessoryAction currently uses implicit Int raw values
which are persisted via ToolbarItemID.builtin(rawValue) -> "builtin.<rawValue>"
and will break when cases are reordered/inserted; make
TerminalInputAccessoryAction an Int-backed enum with explicit, stable rawValue
assignments for every case (and avoid reusing values), so itemID (the var
itemID: ToolbarItemID { .builtin(rawValue) }) continues to return the same
storageKey across versions; add a short comment on each case noting that the
numeric value is stable and reserved to prevent accidental reordering or reuse.

@lawrencecchen
lawrencecchen dismissed coderabbitai[bot]’s stale review June 6, 2026 11:31

Dismissed: CodeRabbit now posts non-blocking comment reviews (request_changes_workflow=false, #5538).

…om-actions

# Conflicts:
#	Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift
#	Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
#	Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift
#	ios/cmux/Resources/Localizable.xcstrings

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 77cc9c4. Configure here.

// view controller.
guard let presenter = presentingController(for: surfaceView) else { return }
let editor = UIHostingController(rootView: TerminalShortcutsSettingsView())
presenter.present(editor, animated: true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Customize stacks settings modals

Medium Severity

Each tap on the toolbar customize control calls present on the top view controller without checking whether TerminalShortcutsSettingsView is already presented. Repeated taps stack multiple identical settings screens; Done only dismisses the topmost one.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 77cc9c4. Configure here.

title: trimmedTitle,
symbolName: nil,
payload: .text(text)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit overwrites keyCombo payloads

Low Severity

Saving from the custom-action editor always writes a text payload and clears symbolName, even when the action being edited used keyCombo. Swipe-to-edit in shortcuts settings does not block those actions.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 77cc9c4. Configure here.

@lawrencecchen
lawrencecchen merged commit 23432d8 into main Jun 6, 2026
27 of 29 checks passed
lawrencecchen added a commit that referenced this pull request Jun 7, 2026
- Defer composer field focus one runloop after appear so it reliably takes
  first responder from the terminal input while the keyboard is up (inline
  onAppear focus was unreliable; it now hands the keyboard over in place).
- Give the field pill and the round send/dismiss buttons a shared 40pt control
  height so the single-line composer lines up; the field still grows multi-line.

The accessory-bar glass restyle from the original commit is dropped on rebase:
it targeted the pre-#5532 button.tag toolbar API, which #5510/#5532 replaced
with AccessoryActionButton + .item. The composer button rides main's toolbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 77cc9c46 Deployed Jun 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant