Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; };
A5001652 /* CmuxConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* CmuxConfigExecutor.swift */; };
A5001654 /* CmuxDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxDirectoryTrust.swift */; };
A5001658 /* KeybindingsConfigFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001656 /* KeybindingsConfigFile.swift */; };
A5001659 /* CustomCommandStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001657 /* CustomCommandStore.swift */; };
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; };
A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; };
A5007422 /* BrowserWebAuthnSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007423 /* BrowserWebAuthnSupport.swift */; };
Expand Down Expand Up @@ -297,6 +299,8 @@
A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = "<group>"; };
A5001653 /* CmuxConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigExecutor.swift; sourceTree = "<group>"; };
A5001655 /* CmuxDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxDirectoryTrust.swift; sourceTree = "<group>"; };
A5001656 /* KeybindingsConfigFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindingsConfigFile.swift; sourceTree = "<group>"; };
A5001657 /* CustomCommandStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandStore.swift; sourceTree = "<group>"; };
A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; };
A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; };
A5007423 /* BrowserWebAuthnSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebAuthnSupport.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -485,6 +489,8 @@
A50012F0 /* Backport.swift */,
A50012F2 /* KeyboardShortcutSettings.swift */,
A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */,
A5001656 /* KeybindingsConfigFile.swift */,
A5001657 /* CustomCommandStore.swift */,
A50012F4 /* KeyboardLayout.swift */,
A5001013 /* TabManager.swift */,
A5001511 /* UITestRecorder.swift */,
Expand Down Expand Up @@ -845,6 +851,8 @@
A50012F1 /* Backport.swift in Sources */,
A50012F3 /* KeyboardShortcutSettings.swift in Sources */,
A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */,
A5001658 /* KeybindingsConfigFile.swift in Sources */,
A5001659 /* CustomCommandStore.swift in Sources */,
A50012F5 /* KeyboardLayout.swift in Sources */,
A5001003 /* TabManager.swift in Sources */,
A5001501 /* UITestRecorder.swift in Sources */,
Expand Down
12 changes: 12 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2584,6 +2584,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
#endif

_ = CustomCommandStore.shared

if telemetryEnabled {
// Pre-warm locale before Sentry to avoid a startup data race.
// Locale initialization (os.locale.ensureLocale / NSLocale._preferredLanguages)
Expand Down Expand Up @@ -11441,6 +11443,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}

if activeConfiguredShortcutChordPrefixForCurrentEvent == nil,
let customCommand = KeyboardShortcutSettings.matchingCustomCommand(for: event) {
return tabManager?.openSurfaceAndRunCommand(
target: customCommand.target,
cwd: customCommand.resolvedWorkingDirectory,
command: customCommand.command,
customCommandID: customCommand.id
) ?? false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Custom commands silently shadow later built-in shortcuts

Medium Severity

The custom command shortcut check is inserted in the middle of the built-in shortcut processing chain — after core shortcuts like newSurface but before many others including openBrowser, find, focusBrowserAddressBar, browser navigation, developer tools, zoom, and more. A user-defined custom command whose shortcut collides with any of those later built-in shortcuts will silently shadow the built-in, while a collision with an earlier shortcut silently ignores the custom command. There is no conflict detection or warning, and the user has no way to predict which built-in shortcuts are "overridable" since the priority depends entirely on internal evaluation order.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 91d4d59. Configure here.


// Open browser: Cmd+Shift+L
if matchConfiguredShortcut(event: event, action: .openBrowser) {
_ = openBrowserAndFocusAddressBar(insertAtEnd: true)
Expand Down
103 changes: 103 additions & 0 deletions Sources/CustomCommandStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import AppKit
import Foundation

final class CustomCommandStore {
static let shared = CustomCommandStore()

private struct ResolvedBinding {
let binding: CustomCommandBinding
let shortcut: StoredShortcut
}

private let fileManager: FileManager
private let path: String
private var watcher: ShortcutSettingsFileWatcher?
private var bindings: [ResolvedBinding] = []

init(
fileManager: FileManager = .default,
path: String? = nil
) {
self.fileManager = fileManager
self.path = path ?? Self.defaultPath(fileManager: fileManager)
watcher = ShortcutSettingsFileWatcher(path: self.path, fileManager: fileManager) { [weak self] in
DispatchQueue.main.async {
self?.reload()
}
}
reload()
}

func reload() {
guard fileManager.fileExists(atPath: path),
let data = fileManager.contents(atPath: path),
!data.isEmpty else {
bindings = []
return
}

do {
let sanitized = try JSONCParser.preprocess(data: data)
let schema = try JSONDecoder().decode(KeybindingsConfigFile.Schema.self, from: sanitized)

@cubic-dev-ai cubic-dev-ai Bot Apr 19, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A single malformed entry in custom_commands (e.g. a typo like "split_left" in the target field) causes JSONDecoder().decode to throw for the entire array, and the catch block wipes bindings = []. This means one bad entry silently disables every other working custom-command shortcut. Decode each entry individually (e.g. decode as [AnyCodable] or wrap per-element decode in try/catch) so that one invalid entry is skipped while the rest continue to work.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/CustomCommandStore.swift, line 41:

<comment>A single malformed entry in `custom_commands` (e.g. a typo like `"split_left"` in the `target` field) causes `JSONDecoder().decode` to throw for the entire array, and the `catch` block wipes `bindings = []`. This means one bad entry silently disables every other working custom-command shortcut. Decode each entry individually (e.g. decode as `[AnyCodable]` or wrap per-element decode in try/catch) so that one invalid entry is skipped while the rest continue to work.</comment>

<file context>
@@ -0,0 +1,103 @@
+
+        do {
+            let sanitized = try JSONCParser.preprocess(data: data)
+            let schema = try JSONDecoder().decode(KeybindingsConfigFile.Schema.self, from: sanitized)
+            bindings = resolveBindings(schema.custom_commands ?? [])
+        } catch {
</file context>
Fix with Cubic

bindings = resolveBindings(schema.custom_commands ?? [])
} catch {
NSLog("[CustomCommandStore] parse error at %@: %@", path, String(describing: error))
bindings = []
}
}

func matchingCommand(for event: NSEvent) -> CustomCommandBinding? {
bindings.first { $0.shortcut.matches(event: event) }?.binding
}

private func resolveBindings(_ rawBindings: [CustomCommandBinding]) -> [ResolvedBinding] {
var seenIDs = Set<String>()
var resolved: [ResolvedBinding] = []

for binding in rawBindings {
let id = binding.id.trimmingCharacters(in: .whitespacesAndNewlines)
let shortcutString = binding.shortcut.trimmingCharacters(in: .whitespacesAndNewlines)
let command = binding.command.trimmingCharacters(in: .whitespacesAndNewlines)

guard !id.isEmpty else {
NSLog("[CustomCommandStore] ignoring custom command with empty id in %@", path)
continue
}
guard seenIDs.insert(id).inserted else {
NSLog("[CustomCommandStore] ignoring duplicate custom command id '%@' in %@", id, path)
continue
}
guard !shortcutString.isEmpty,
let shortcut = StoredShortcut.parse(rawValue: shortcutString),
!shortcut.hasChord else {
NSLog("[CustomCommandStore] ignoring custom command '%@' with invalid shortcut '%@' in %@", id, binding.shortcut, path)
continue
Comment on lines +70 to +74

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 hasChord guard is dead code

StoredShortcut.parse(rawValue:) calls StoredShortcut.parse(strokes: [rawValue]) which always produces a single-stroke shortcut, making hasChord always false. The guard !shortcut.hasChord can never trigger here, so users don't get a clear log message when they accidentally write a chord like "cmd+k cmd+j" — the shortcut string is just silently accepted as-is (or rejected earlier if the second stroke can't be parsed from the +-split logic). Consider either documenting that chord syntax is unsupported at the config level, or parsing strokes by splitting on space and passing an array, so the hasChord guard actually functions.

}
guard !command.isEmpty else {
NSLog("[CustomCommandStore] ignoring custom command '%@' with empty command in %@", id, path)
continue
}

resolved.append(
ResolvedBinding(
binding: CustomCommandBinding(
id: id,
shortcut: shortcutString,
command: command,
label: binding.label?.trimmingCharacters(in: .whitespacesAndNewlines),
target: binding.target,
cwd: binding.cwd
),
shortcut: shortcut
)
)
}

return resolved
Comment on lines +53 to +96

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 | 🟡 Minor

Reject duplicate custom-command shortcuts.

matchingCommand returns the first match, so two entries with different IDs but the same shortcut make the later command unreachable without any load-time warning. Track resolved shortcuts and ignore duplicates explicitly.

Proposed fix
     private func resolveBindings(_ rawBindings: [CustomCommandBinding]) -> [ResolvedBinding] {
         var seenIDs = Set<String>()
+        var seenShortcuts: [StoredShortcut] = []
         var resolved: [ResolvedBinding] = []
@@
             guard !shortcutString.isEmpty,
                   let shortcut = StoredShortcut.parse(rawValue: shortcutString),
                   !shortcut.hasChord else {
                 NSLog("[CustomCommandStore] ignoring custom command '%@' with invalid shortcut '%@' in %@", id, binding.shortcut, path)
                 continue
             }
+            guard !seenShortcuts.contains(shortcut) else {
+                NSLog("[CustomCommandStore] ignoring custom command '%@' with duplicate shortcut '%@' in %@", id, shortcutString, path)
+                continue
+            }
             guard !command.isEmpty else {
                 NSLog("[CustomCommandStore] ignoring custom command '%@' with empty command in %@", id, path)
                 continue
             }
+            seenShortcuts.append(shortcut)
 
             resolved.append(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CustomCommandStore.swift` around lines 53 - 96, The resolveBindings
function currently only deduplicates ids but not shortcut collisions; add a Set
to track seen shortcuts (e.g., seenShortcuts: Set<StoredShortcut> or Set<String>
using the parsed shortcut.rawValue) and, after successfully parsing
StoredShortcut in resolveBindings, check whether the parsed shortcut is already
in seenShortcuts; if it is, log a message similar to the existing NSLog that you
are ignoring the duplicate shortcut for the later CustomCommandBinding (include
id, binding.shortcut, path) and continue, otherwise insert the shortcut into
seenShortcuts and proceed to append the ResolvedBinding; update references
around ResolvedBinding, CustomCommandBinding and StoredShortcut.parse
accordingly.

}

private static func defaultPath(fileManager: FileManager) -> String {
let home = fileManager.homeDirectoryForCurrentUser.path
return (home as NSString).appendingPathComponent(".config/cmux/keybindings.json")
}
}
66 changes: 66 additions & 0 deletions Sources/KeybindingsConfigFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Foundation

enum KeybindingsConfigFile {
struct Schema: Codable {
var version: Int?
var custom_commands: [CustomCommandBinding]?
}
}

struct CustomCommandBinding: Codable, Equatable, Identifiable {
var id: String
var shortcut: String
var command: String
var label: String?
var target: CustomCommandTarget
var cwd: CustomCommandWorkingDirectory?

var resolvedWorkingDirectory: CustomCommandWorkingDirectory {
cwd ?? .workspace
}
}
Comment on lines +10 to +21

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.

P1 All bindings silently wiped on any single decode error

CustomCommandTarget and CustomCommandWorkingDirectory are non-optional, non-defaulting Codable types. If any entry in custom_commands has an unknown target string (e.g. a typo like "split_left") or a missing required field, JSONDecoder will throw before resolveBindings even runs, and the catch block clears bindings = [] — wiping every previously-working shortcut. The per-entry guard logic in resolveBindings never has a chance to isolate the bad entry.

A user who adds a second binding with a typo in target will silently lose all their other bindings with only an NSLog message to explain it. Consider decoding custom_commands as [AnyCodable] (or wrapping each element decode in a try-catch) so that one bad entry is skipped and the rest continue to work.


enum CustomCommandTarget: String, Codable, Equatable {
case splitRight = "split_right"
case splitDown = "split_down"
case newSurface = "new_surface"
case newTab = "new_tab"
case newWorkspace = "new_workspace"
}

enum CustomCommandWorkingDirectory: Codable, Equatable {
case workspace
case pane
case absolutePath(String)

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
switch rawValue {
case "workspace":
self = .workspace
case "pane":
self = .pane
default:
guard rawValue.hasPrefix("/") else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "cwd must be \"workspace\", \"pane\", or an absolute path"
)
}
self = .absolutePath(rawValue)
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .workspace:
try container.encode("workspace")
case .pane:
try container.encode("pane")
case .absolutePath(let path):
try container.encode(path)
}
}
}
107 changes: 107 additions & 0 deletions Sources/KeyboardShortcutSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ enum KeyboardShortcutSettings {
static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange")
static let actionUserInfoKey = "action"
static let settingsFileDisplayPath = "~/.config/cmux/settings.json"
static let keybindingsFileDisplayPath = "~/.config/cmux/keybindings.json"
static var settingsFileStore: KeyboardShortcutSettingsFileStore = .shared {
didSet {
notifySettingsFileDidChange()
}
}
static var customCommandStore: CustomCommandStore = .shared

enum Action: String, CaseIterable, Identifiable {
// App / window
Expand Down Expand Up @@ -407,6 +409,10 @@ enum KeyboardShortcutSettings {
return String(localized: "settings.shortcuts.managedByFile", defaultValue: "Managed in settings.json")
}

static func matchingCustomCommand(for event: NSEvent) -> CustomCommandBinding? {
customCommandStore.matchingCommand(for: event)
}

static func setShortcut(_ shortcut: StoredShortcut, for action: Action) {
guard !isManagedBySettingsFile(action) else { return }

Expand Down Expand Up @@ -1009,6 +1015,46 @@ struct ShortcutStroke: Equatable {
return stroke
}

static func parse(rawValue: String) -> ShortcutStroke? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else {
return nil
}

var command = false
var shift = false
var option = false
var control = false

for modifier in parts.dropLast() {
switch modifier.lowercased() {
case "cmd", "command", "⌘":
command = true
case "shift", "⇧":
shift = true
case "opt", "option", "alt", "⌥":
option = true
case "ctrl", "control", "ctl", "⌃":
control = true
default:
return nil
}
}

guard let key = parseKeyToken(String(lastPart)) else { return nil }
return ShortcutStroke(
key: key,
command: command,
shift: shift,
option: option,
control: control
)
}

static func normalizedModifierFlags(from flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags {
flags.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
Expand Down Expand Up @@ -1137,6 +1183,52 @@ struct ShortcutStroke: Equatable {
return nil
}

static func parseKeyToken(_ rawValue: String) -> String? {
let lowered = rawValue.lowercased()
switch lowered {
case "left", "arrowleft", "leftarrow", "←":
return "←"
case "right", "arrowright", "rightarrow", "→":
return "→"
case "up", "arrowup", "uparrow", "↑":
return "↑"
case "down", "arrowdown", "downarrow", "↓":
return "↓"
case "tab":
return "\t"
case "return", "enter", "↩":
return "\r"
case "space":
return " "
case "comma":
return ","
case "period", "dot":
return "."
case "slash":
return "/"
case "backslash":
return "\\"
case "semicolon":
return ";"
case "quote", "apostrophe":
return "'"
case "backtick", "grave":
return "`"
case "minus", "hyphen":
return "-"
case "plus", "equals":
return "="
case "leftbracket", "openbracket":
return "["
case "rightbracket", "closebracket":
return "]"
default:
let normalized = lowered.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized.count == 1 else { return nil }
return normalized
}
}

static func normalizedShortcutEventCharacter(
_ eventCharacter: String,
applyShiftSymbolNormalization: Bool,
Expand Down Expand Up @@ -1492,6 +1584,21 @@ struct StoredShortcut: Codable, Equatable {
return StoredShortcut(first: stroke)
}

static func parse(rawValue: String) -> StoredShortcut? {
parse(strokes: [rawValue])
}

static func parse(strokes: [String]) -> StoredShortcut? {
guard !strokes.isEmpty, strokes.count <= 2 else { return nil }
let parsedStrokes = strokes.compactMap(ShortcutStroke.parse(rawValue:))
guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else {
return nil
}
guard !firstStroke.modifierFlags.isEmpty else { return nil }
let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil
return StoredShortcut(first: firstStroke, second: secondStroke)
}

func matches(
event: NSEvent,
layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
Expand Down
Loading
Loading