Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3c6c14c
Add custom keyboard shortcuts for Command Palette commands
Jun 26, 2026
8009023
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 26, 2026
d152478
Refresh Swift file-length budget for shortcuts.commands additions
Jun 26, 2026
c08dacb
Address review: lossless command-shortcut persistence + localized docs
Jun 26, 2026
0ba2519
Address review round 2: package-type witness, lenient conflict base, …
Jun 26, 2026
541d5e9
Address review round 3: Sendable setMapEntry + cached hot-path dispatch
Jun 26, 2026
ca5ec8f
Address review round 4: schema accepts Settings object form; no focus…
Jun 26, 2026
3046917
Refresh Swift file-length budget
Jun 26, 2026
ab8738e
Address review round 5: read built-in bindings fresh for command conf…
Jun 26, 2026
4b1888d
Address review round 6: present palette for in-palette command shortcuts
Jun 26, 2026
05879fd
Address review round 7: reject bare Space for command shortcuts
Jun 26, 2026
3c3be02
Address review round 8: fix StoredShortcut API in parse test (compile…
Jun 26, 2026
9d6c84c
Address review round 9: include configured cmux actions in conflict c…
Jun 26, 2026
129a733
Address review round 10: configured-action conflicts use a list, not …
Jun 26, 2026
0fe16e6
Address cmux-policy-check: DocC docs + split types into own files
Jun 26, 2026
114d659
Address review round 11: schema requires a modifier for command short…
Jun 26, 2026
bfeeb84
Address review round 12: unbind writes a marker; config conflicts sur…
Jun 26, 2026
e3fbb9d
Address review round 13: live command context + aggregate window configs
Jun 26, 2026
50149b0
Fix compile error: make static palette-contribution helpers static (r…
Jun 26, 2026
7973068
Address review round 14: require a primary modifier for command short…
Jun 26, 2026
f114b63
Localize shortcutsCommandsIntro for all 20 locales (CodeRabbit)
Jun 26, 2026
7a2dc05
Localize custom command shortcut docs to all supported locales
Jun 26, 2026
5c3c5dc
Bound per-keystroke command-shortcut dispatch with a modifier-mask index
Jun 26, 2026
a9a5ada
Refresh Swift file-length budget for command-shortcut feature growth
Jun 26, 2026
4cd044a
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 27, 2026
588e602
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 27, 2026
651d6ab
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 27, 2026
524da80
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 29, 2026
b0aaca8
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 29, 2026
f0b2d10
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 29, 2026
6b4c048
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
Jun 29, 2026
52a25b7
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
3244127
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
a86977e
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
582c777
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
7afb05a
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
6cde68e
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
f08cfa1
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
ff4510e
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 2, 2026
ed347cf
Re-trigger CI (dropped push event for prior head)
austinywang Jul 2, 2026
14b5f64
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 3, 2026
19c3892
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 3, 2026
f562a71
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 3, 2026
c987c97
Merge remote-tracking branch 'origin/main' into issue-6431-custom-key…
austinywang Jul 4, 2026
c0e8d95
Address command shortcut review feedback
austinywang Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34499 CLI/cmux.swift
17954 Sources/AppDelegate.swift
16427 Sources/ContentView.swift
18023 Sources/AppDelegate.swift
16501 Sources/ContentView.swift
14270 Sources/TerminalController.swift
13172 Sources/Workspace.swift
12348 cmuxTests/AppDelegateShortcutRoutingTests.swift
Expand All @@ -17,15 +17,15 @@
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
6359 cmuxTests/SessionPersistenceTests.swift
6255 cmuxTests/GhosttyConfigTests.swift
6166 Sources/TabManager.swift
6171 Sources/TabManager.swift
5915 cmuxTests/TerminalAndGhosttyTests.swift
5809 Sources/TextBoxInput.swift
5573 cmuxTests/BrowserConfigTests.swift
4483 Sources/cmuxApp.swift
4482 Sources/Panels/FilePreviewPanel.swift
4367 cmuxTests/BrowserPanelTests.swift
4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
4121 Sources/BrowserWindowPortal.swift
3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3934 Sources/Feed/FeedPanelView.swift
3926 cmuxTests/TabManagerUnitTests.swift
3896 cmuxTests/WindowAndDragTests.swift
Expand All @@ -43,10 +43,10 @@
2328 cmuxTests/CJKIMEInputTests.swift
2242 Sources/TerminalNotificationStore.swift
2229 Sources/TerminalWindowPortal.swift
2153 Sources/KeyboardShortcutSettingsFileStore.swift
2126 cmuxTests/CmuxConfigTests.swift
2085 cmuxTests/ShortcutAndCommandPaletteTests.swift
2078 Sources/SessionPersistence.swift
2011 Sources/KeyboardShortcutSettingsFileStore.swift
1944 Sources/RestorableAgentSession.swift
1900 cmuxTests/NotificationAndMenuBarTests.swift
1866 Sources/Panels/BrowserWebAuthnSupport.swift
Expand Down Expand Up @@ -126,6 +126,7 @@
797 Sources/ClosedItemHistory.swift
782 CLI/CMUXCLI+AgentHookDefinitions.swift
779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift
774 cmuxTests/CommandPaletteShortcutCustomizationTests.swift
773 Sources/App/MenuBarExtraController.swift
769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
768 cmuxUITests/BrowserFixtureInteractionUITests.swift
Expand Down Expand Up @@ -173,7 +174,7 @@
637 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
635 cmuxUITests/RightSidebarChromeHeightUITests.swift
630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift
620 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift
627 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift
620 cmuxTests/FinderFileDropRegressionTests.swift
620 cmuxTests/TerminalNotificationQueueTests.swift
617 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift
Expand All @@ -186,13 +187,13 @@
602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift
601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift
598 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift
596 Sources/HostSettingsActions.swift
596 cmuxTests/CmuxEventBusTests.swift
594 cmuxTests/PortalTabDragRoutingTests.swift
592 Sources/TextBoxSubmitActions.swift
591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift
590 Sources/Panels/BrowserNavigationDelegate.swift
589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift
588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift
586 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift
586 Sources/JSONCParser.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,17 @@ public struct KeyboardShortcutsCatalogSection: SettingCatalogSection {
defaultValue: [:]
)

/// User-assigned shortcuts for Command Palette commands, keyed by command
/// id (e.g. `palette.openFolderInVSCode`). Single-stroke only. Unlike
/// ``bindings`` (which overrides built-in cmux actions), these let a user
/// bind a shortcut to any palette command that ships without one. The app
/// target dispatches them on the focused window; the Settings UI's
/// "Custom Commands" section records and conflict-checks them.
/// ``StoredShortcut/unbound`` for a command represents an explicit clear.
public let commands = JSONKey<[String: StoredShortcut]>(
id: "shortcuts.commands",
defaultValue: [:]
)

public init() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,42 @@ public actor JSONConfigStore {
}
}

/// Sets or removes a single entry in a string-keyed map at `key`, preserving
/// every *other* entry's raw on-disk form.
///
/// Unlike ``set(_:for:)`` — which replaces the whole map with a typed,
/// re-encoded value and therefore drops any sibling this process cannot
/// decode (e.g. a shortcut written in string form `"cmd+n"` when the typed
/// value is the object form) — this reads the map as raw JSON, mutates only
/// `entryKey`, and writes it back. Pass `value: nil` to remove the entry.
/// The map (and its parents) are pruned when the last entry is removed.
///
/// The value is taken as the typed (`Sendable`) `Value` and encoded *inside*
/// the actor (matching ``set(_:for:)``) so no non-`Sendable` `Any` crosses
/// the actor boundary; only the sibling entries are handled as raw JSON, and
/// they never leave this method.
///
/// - Throws: Errors from `FileManager` or `JSONSerialization` writing the file.
public func setMapEntry<Value: SettingCodable>(
_ value: Value?,
forKey entryKey: String,
in key: JSONKey<[String: Value]>
) throws {
try mutateRoot { root in
var map = (key.path.lookup(in: root) as? [String: Any]) ?? [:]
if let value {
map[entryKey] = value.encodeForJSON()
} else {
map.removeValue(forKey: entryKey)
}
if map.isEmpty {
key.path.remove(in: &root)
} else {
key.path.assign(map, in: &root)
}
}
}

/// Returns an `AsyncStream` that yields the current value and every later change.
///
/// - First element is yielded as soon as the consumer starts iterating.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ struct JSONConfigStoreTests {
#expect(parsed?["automation"] == nil)
}

@Test func setMapEntryPreservesSiblingRawEntries() async throws {
let (store, fileURL, catalog) = makeStore()
// A user hand-wrote a string-form command shortcut (the documented
// format), which the typed `[String: StoredShortcut]` decode cannot
// read. A full `set(_:)` of the map would drop it; `setMapEntry` must
// preserve it while adding a different command's binding.
let json = #"{"shortcuts":{"commands":{"palette.newWindow":"cmd+ctrl+n"}}}"#
try Data(json.utf8).write(to: fileURL)

let objectForm = StoredShortcut(first: ShortcutStroke(key: "y", command: true, option: true, control: true))
try await store.setMapEntry(objectForm, forKey: "palette.openFolder", in: catalog.shortcuts.commands)

let data = try Data(contentsOf: fileURL)
let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let commands = (parsed?["shortcuts"] as? [String: Any])?["commands"] as? [String: Any]
#expect(commands?["palette.newWindow"] as? String == "cmd+ctrl+n")
#expect(commands?["palette.openFolder"] is [String: Any])
}

@Test func setMapEntryRemovesEntryAndPrunesEmptyMap() async throws {
let (store, fileURL, catalog) = makeStore()
let json = #"{"shortcuts":{"commands":{"palette.newWindow":"cmd+ctrl+n"}}}"#
try Data(json.utf8).write(to: fileURL)

try await store.setMapEntry(StoredShortcut?.none, forKey: "palette.newWindow", in: catalog.shortcuts.commands)

let data = try Data(contentsOf: fileURL)
let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let shortcuts = parsed?["shortcuts"] as? [String: Any]
#expect(shortcuts?["commands"] == nil)
}

@Test func toleratesJSONCComments() async throws {
let (store, fileURL, _) = makeStore()
let json = """
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CmuxSettings
import Foundation

/// Host-supplied callbacks the package's section views invoke for
Expand Down Expand Up @@ -147,6 +148,50 @@ public protocol SettingsHostActions: AnyObject {
/// `async` because the availability check probes a real bind.
func applyMobilePairingPort(_ port: Int) async -> MobilePairingPortApplyResult

/// The full catalog of Command Palette commands a user can bind a custom
/// keyboard shortcut to, in a stable display order. Backed by the host's
/// live command contributions, so it lives here rather than in the catalog.
/// The **Custom Commands** keyboard-shortcut section reads this to resolve a
/// bound command id back to a title and to seed the picker's default list.
/// Returns an empty array for hosts without a live palette (previews/tests).
func commandShortcutCatalog() -> [CommandShortcutCatalogEntry]

/// Ranks the command-shortcut catalog for `query` using the **same ranking
/// engine as the Command Palette**, returning at most `limit` entries best
/// first. An empty query yields the default catalog order (capped to
/// `limit`). Returns an empty array for hosts without a live palette.
func searchCommandShortcutCatalog(query: String, limit: Int) -> [CommandShortcutCatalogEntry]

/// The user's currently-bound command shortcuts (`shortcuts.commands`),
/// keyed by command id, parsed by the **host's** lenient config reader so a
/// value written in *any* documented form (string `"cmd+n"`, object, or an
/// unbind marker) resolves correctly. The Settings **Custom Commands**
/// section uses this for the bound list and conflict detection rather than
/// the package's object-only typed decode, which would silently drop a
/// string-form binding. Returns an empty map for hosts without a config
/// reader (previews/tests).
func commandShortcuts() -> [String: StoredShortcut]

/// The effective (override-or-default) shortcut for every built-in cmux
/// action, keyed by action id, resolved by the **host's** lenient reader.
/// The Custom Commands conflict check uses this instead of the package's
/// `shortcuts.bindings` typed decode, which is object-only and all-or-nothing
/// — a single string-form override there would blank the whole map and let a
/// command silently shadow a rebound built-in. Empty for hosts without a
/// config reader (previews/tests).
func effectiveActionShortcuts() -> [String: StoredShortcut]

/// The shortcuts of user-defined cmux config actions (cmux.json `actions`
/// with a `shortcut`), as `(displayLabel, shortcut)` pairs. The runtime key
/// router dispatches these *before* custom command shortcuts, so the Custom
/// Commands conflict check must include them — otherwise a command could be
/// bound to a keystroke a configured action already owns and never fire. The
/// stored shortcut's first stroke is compared, which also catches chord
/// prefixes. Returned as a list rather than a title-keyed map because action
/// titles are free-form and may collide. Empty for hosts without a config
/// store (previews/tests).
func configuredActionShortcuts() -> [(label: String, shortcut: StoredShortcut)]

/// Shows the Sleepy Mode screensaver as a non-locking preview (any key/click
/// exits, no Touch ID). The host owns the overlay window.
func sleepyModePreview()
Expand Down Expand Up @@ -199,18 +244,41 @@ public extension SettingsHostActions {
(1...65535).contains(port) ? .savedForLater(port: port) : .invalid(requestedPort: port)
}

/// Default: empty catalog, for hosts without a live Command Palette.
func commandShortcutCatalog() -> [CommandShortcutCatalogEntry] { [] }

/// Default: no bound command shortcuts, for hosts without a config reader.
func commandShortcuts() -> [String: StoredShortcut] { [:] }

/// Default: no built-in action shortcuts, for hosts without a config reader.
func effectiveActionShortcuts() -> [String: StoredShortcut] { [:] }

/// Default: no configured action shortcuts, for hosts without a config store.
func configuredActionShortcuts() -> [(label: String, shortcut: StoredShortcut)] { [] }

/// Default: a simple case-insensitive substring filter over the catalog so
/// preview/test hosts still render a usable picker without the app's engine.
func searchCommandShortcutCatalog(query: String, limit: Int) -> [CommandShortcutCatalogEntry] {
defaultCommandShortcutCatalogSearch(in: commandShortcutCatalog(), query: query, limit: limit)
}

/// Default sidebar font size + range, for hosts without a Ghostty config.
func sidebarFontSize() -> SettingsFontSize {
SettingsFontSize(points: 12.5, minimum: 10, maximum: 20, defaultValue: 12.5)
}

/// Default: reports success without persisting, for hosts with no config writer.
func setSidebarFontSize(_ points: Double) async -> Bool { true }

/// Default workspace tab-bar font size + range, for hosts without a config.
func surfaceTabBarFontSize() -> SettingsFontSize {
SettingsFontSize(points: 11, minimum: 8, maximum: 14, defaultValue: 11)
}

/// Default: reports success without persisting, for hosts with no config writer.
func setSurfaceTabBarFontSize(_ points: Double) async -> Bool { true }

/// Default point-size formatter, trimming trailing zeros (e.g. `12`, `13.5`).
func formattedFontSize(_ points: Double) -> String {
let scaled = (points * 100).rounded()
let whole = Int(scaled / 100)
Expand Down Expand Up @@ -243,3 +311,27 @@ public final class NoopSettingsHostActions: SettingsHostActions {
public func previewNotificationSound(value: String, customFilePath: String) {}
public func browserHistoryEntryCount() -> Int? { nil }
}

/// Case-insensitive substring filter used by the default
/// ``SettingsHostActions/searchCommandShortcutCatalog(query:limit:)`` so
/// preview/test hosts render a usable picker without the app's ranking engine.
/// File-private so it stays out of the public protocol surface.
private func defaultCommandShortcutCatalogSearch(
in catalog: [CommandShortcutCatalogEntry],
query: String,
limit: Int
) -> [CommandShortcutCatalogEntry] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
let filtered: [CommandShortcutCatalogEntry]
if trimmed.isEmpty {
filtered = catalog
} else {
let needle = trimmed.lowercased()
filtered = catalog.filter { entry in
entry.title.lowercased().contains(needle)
|| entry.subtitle.lowercased().contains(needle)
|| entry.keywords.contains { $0.lowercased().contains(needle) }
}
}
return limit >= 0 ? Array(filtered.prefix(limit)) : filtered
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import CmuxSettings
import Foundation

/// Detects whether a proposed single-stroke command shortcut collides with an
/// existing built-in action binding, a configured cmux action, or another
/// command binding.
///
/// A value type constructed with the current bindings (rather than a free
/// function) so the conflict surface is owned, testable, and not ambient module
/// API. Built-in actions are checked with numbered-digit family semantics so
/// binding `⌃5` is blocked by the `⌃1…9` workspace selector; the proposed
/// command stroke is never itself a numbered family.
@MainActor
struct CommandShortcutConflictChecker {
/// Built-in action overrides from `shortcuts.bindings`.
let actionBindings: [String: StoredShortcut]
/// User-defined cmux config action shortcuts (cmux.json `actions`) as
/// `(displayLabel, shortcut)` pairs. Dispatched before custom command
/// shortcuts at runtime, so a command must not be allowed to bind a keystroke
/// one of these owns. A list, not a map: action titles may collide.
let configuredActionShortcuts: [(label: String, shortcut: StoredShortcut)]
/// Other commands' bindings from `shortcuts.commands`.
let commandShortcuts: [String: StoredShortcut]
/// Resolves a command id to its display title for the banner.
let title: (String) -> String

/// The display label of the first conflicting binding — a built-in action's
/// display name, a configured action's label, or another command's title —
/// or `nil` when `stroke` is free. Pass the command id being rebound as
/// `excludingCommandId` so a command's own existing binding is not treated
/// as a self-conflict. Comparisons use the first stroke, which also catches a
/// chord prefix (binding a command to a chord's first stroke would arm the
/// chord and swallow the key).
func conflictLabel(stroke: StoredShortcut, excludingCommandId: String?) -> String? {
for action in ShortcutAction.allCases {
let effective = actionBindings[action.rawValue] ?? action.defaultShortcut
guard let effective, !effective.isUnbound else { continue }
if numberedAwareStrokesConflict(
stroke.first,
numbered: false,
effective.first,
numbered: action.usesNumberedDigitMatching
) {
return action.displayName
}
}
for entry in configuredActionShortcuts {
guard !entry.shortcut.isUnbound else { continue }
if numberedAwareStrokesConflict(stroke.first, numbered: false, entry.shortcut.first, numbered: false) {
return entry.label
}
}
for (commandId, existing) in commandShortcuts where commandId != excludingCommandId {
guard !existing.isUnbound else { continue }
if numberedAwareStrokesConflict(stroke.first, numbered: false, existing.first, numbered: false) {
return title(commandId)
}
}
return nil
}
}
Loading
Loading