-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Add custom command shortcut spawning #3021
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A single malformed entry in Prompt for AI agents |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject duplicate custom-command shortcuts.
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 |
||
| } | ||
|
|
||
| private static func defaultPath(fileManager: FileManager) -> String { | ||
| let home = fileManager.homeDirectoryForCurrentUser.path | ||
| return (home as NSString).appendingPathComponent(".config/cmux/keybindings.json") | ||
| } | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A user who adds a second binding with a typo in |
||
|
|
||
| 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) | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
newSurfacebut before many others includingopenBrowser,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.Reviewed by Cursor Bugbot for commit 91d4d59. Configure here.