Skip to content

Add custom command shortcut spawning - #3021

Closed
austinywang wants to merge 3 commits into
mainfrom
issue-3019-preset-command-shortcut
Closed

austinywang wants to merge 3 commits into
mainfrom
issue-3019-preset-command-shortcut

Conversation

@austinywang

@austinywang austinywang commented Apr 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a live-reloaded ~/.config/cmux/keybindings.json custom command store for preset shortcut bindings
  • spawn the requested surface target and inject the configured command after the PTY is ready
  • add a regression test covering shortcut simulation, split creation, cwd propagation, and command execution

Closes #3019


Note

Medium Risk
Adds a new config-driven path that spawns terminal surfaces and injects user-defined commands, which can impact shortcut routing and terminal startup/cwd/env handling. Live-reloading file parsing and new surface creation logic increases the chance of edge-case regressions but is scoped and guarded with validation plus a regression test.

Overview
Adds support for custom command shortcuts loaded from a live-reloaded ~/.config/cmux/keybindings.json, allowing a single-stroke shortcut to open a target surface (split/surface/tab/workspace) and run a configured shell command.

Implements a new CustomCommandStore + KeybindingsConfigFile schema, wires shortcut matching into AppDelegate’s key handling, and adds TabManager.openSurfaceAndRunCommand(...) to resolve CWD policy, inject CMUX_WORKSPACE_CWD/CMUX_PANE_CWD/CMUX_CUSTOM_COMMAND_ID, and send input once the PTY is ready.

Refactors shortcut-string parsing into StoredShortcut.parse/ShortcutStroke.parse (reused by settings file parsing), expands Workspace.newTerminalSplit to accept an override working directory and extra environment, and adds a Python regression test that simulates the shortcut and verifies split creation, cwd/env propagation, and command execution.

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


Summary by cubic

Adds custom command shortcuts via a live‑reloaded ~/.config/cmux/keybindings.json. Press a matching single‑stroke shortcut to open a split/tab/surface/workspace and run the configured command with the correct CWD and env. Addresses #3019.

  • New Features

    • New CustomCommandStore reads and live‑reloads ~/.config/cmux/keybindings.json (JSON/JSONC).
    • Bindings: id, shortcut, command, target (split_right, split_down, new_surface, new_tab, new_workspace), optional label, and cwd (workspace, pane, or absolute path).
    • Shortcuts parsed via StoredShortcut.parse/ShortcutStroke.parse; matched before chord handling; chords are disallowed.
    • TabManager.openSurfaceAndRunCommand(...) spawns target and injects input when PTY is ready; sets CMUX_WORKSPACE_CWD, CMUX_PANE_CWD, CMUX_CUSTOM_COMMAND_ID; resolves CWD per policy.
    • Workspace.newTerminalSplit(...) accepts a working‑directory override and extra environment.
    • Adds a regression test that simulates the shortcut and verifies split creation, CWD/env propagation, and command execution.
  • Migration

    • Create ~/.config/cmux/keybindings.json with a custom_commands array; changes are picked up automatically.
    • Existing settings.json shortcuts are unchanged; custom commands fire only on single‑stroke shortcuts not used in chords.

Written for commit 97adb98. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Custom keyboard shortcuts that run configured commands in new splits, surfaces, tabs, or workspaces
    • Keybindings file support with live reload and robust parsing/validation of custom commands and shortcuts
    • Support for custom working directories and injected environment variables for command execution
  • Tests
    • Regression test verifying custom-command shortcuts create surfaces/splits and execute configured commands with expected cwd and output

@vercel

vercel Bot commented Apr 19, 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 Apr 20, 2026 9:55pm

@coderabbitai

coderabbitai Bot commented Apr 19, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds JSON-configurable custom command shortcuts that are live-reloaded and, when triggered, create new surfaces/splits/tabs/workspaces and run the configured shell command inside them with resolved working directory and environment variables.

Changes

Cohort / File(s) Summary
Xcode project
GhosttyTabs.xcodeproj/project.pbxproj
Added new source file references and build entries for KeybindingsConfigFile.swift and CustomCommandStore.swift.
Config schema
Sources/KeybindingsConfigFile.swift
New Codable types: KeybindingsConfigFile.Schema, CustomCommandBinding, CustomCommandTarget, and CustomCommandWorkingDirectory (custom decode/encode and validation).
Custom command store
Sources/CustomCommandStore.swift
New CustomCommandStore singleton that watches keybindings file, parses JSONC, resolves bindings to StoredShortcut, caches them, and exposes reload() and matchingCommand(for:).
Shortcut parsing & exposure
Sources/KeyboardShortcutSettings.swift
Added keybindingsFileDisplayPath, customCommandStore reference, matchingCustomCommand(for:), and ShortcutStroke / StoredShortcut parsing helpers for shortcut strings.
File-watcher & parser visibility
Sources/KeyboardShortcutSettingsFileStore.swift
Made JSONCParser and ShortcutSettingsFileWatcher module-internal (non-private) and delegated parsing to StoredShortcut.parse(...) (removed local parsers).
Event routing
Sources/AppDelegate.swift
Force-initializes CustomCommandStore.shared at launch and added a custom-command fallback in shortcut handling before some built-in handlers.
Surface creation & command execution
Sources/TabManager.swift, Sources/Workspace.swift
Added TabManager.openSurfaceAndRunCommand(...), helpers to resolve cwd/environment, renamed sendInputWhenReady→sendTerminalInputWhenReady, and extended Workspace.newTerminalSplit(...) to accept overrideWorkingDirectory and startupEnvironment.
Tests
tests_v2/test_custom_command_shortcut.py
New integration test that writes a temp keybindings file, simulates the shortcut, asserts new surface/pane creation, verifies working directory and terminal output containing expected token and env vars.

Sequence Diagram

sequenceDiagram
    participant User
    participant AppDelegate
    participant CustomCommandStore
    participant TabManager
    participant Workspace
    participant Terminal

    User->>AppDelegate: press shortcut (NSEvent)
    AppDelegate->>CustomCommandStore: matchingCommand(for: event)
    CustomCommandStore-->>AppDelegate: CustomCommandBinding? 

    alt binding found
        AppDelegate->>TabManager: openSurfaceAndRunCommand(target,cwd,command,id)
        TabManager->>TabManager: resolve cwd & env
        TabManager->>Workspace: create surface/split/new workspace (with startupEnvironment, overrideWorkingDirectory)
        Workspace->>Terminal: instantiate terminal panel (with env)
        Terminal-->>TabManager: ready
        TabManager->>Terminal: sendTerminalInputWhenReady(command + "\n")
        Terminal->>Terminal: execute command and render output
        Terminal-->>User: display output
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hop, I parse, I bind the key,
A pane appears — hello, do see!
Commands unfurl where new splits grow,
One keystroke makes my tunnels glow,
A rabbit cheers: "Shortcut, run for me!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add custom command shortcut spawning' clearly summarizes the main change—implementing support for custom command shortcuts that spawn new surfaces.
Linked Issues check ✅ Passed The PR successfully implements all core requirements from #3019: custom commands loaded from live-reloaded keybindings.json, surface spawning per target, CWD propagation, environment variables (CMUX_WORKSPACE_CWD, CMUX_PANE_CWD, CMUX_CUSTOM_COMMAND_ID), and a regression test.
Out of Scope Changes check ✅ Passed All changes are directly aligned with implementing custom command shortcuts as specified in #3019; refactoring of shortcut parsing into reusable methods and workspace infrastructure updates are necessary in-scope changes supporting the feature.
Description check ✅ Passed The PR description comprehensively covers what changed, why, how it was tested, and includes a detailed technical overview with migration guidance.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 issue-3019-preset-command-shortcut

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 Apr 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a live-reloaded ~/.config/cmux/keybindings.json custom command shortcut system: a new CustomCommandStore singleton watches the file via FSEvents, parses bindings on change, and AppDelegate.performKeyEquivalent dispatches to TabManager.openSurfaceAndRunCommand which spawns the requested surface target, injects environment variables (CMUX_CUSTOM_COMMAND_ID, CMUX_WORKSPACE_CWD, CMUX_PANE_CWD), and fires the command once the PTY is ready. The parsing refactor (extracting ShortcutStroke.parse / parseKeyToken from the private file store) is clean.

  • P1 – silent wipe of all bindings on any single decode error: because CustomCommandTarget and CustomCommandWorkingDirectory are non-optional Codable types, a single typo in any entry (e.g. \"target\": \"split_left\") causes JSONDecoder to throw before resolveBindings can isolate the bad entry, and the catch block resets bindings = [], silently disabling every working shortcut.

Confidence Score: 4/5

Safe to merge after addressing the all-or-nothing JSON decode failure that silently wipes valid bindings when any single entry has a bad target value.

One P1 issue remains: a malformed target in any single binding silently disables every custom command shortcut. The rest of the implementation is solid — FSEvents watcher, per-entry validation in resolveBindings, environment injection, and the E2E regression test all look correct.

Sources/KeybindingsConfigFile.swift — the non-optional CustomCommandTarget field causes whole-array decode failures that need to be isolated to the offending entry.

Important Files Changed

Filename Overview
Sources/KeybindingsConfigFile.swift New file defining schema types for keybindings.json; non-optional target field causes all-or-nothing decode failures when any single entry is malformed.
Sources/CustomCommandStore.swift New singleton store that live-reloads keybindings.json via FSEvents watcher and exposes matchingCommand(for:) for key-event dispatch; per-entry validation in resolveBindings is sound but never fires when JSON decode itself throws.
Sources/TabManager.swift Adds openSurfaceAndRunCommand and supporting helpers; newTab/newWorkspace targets are currently identical, and sendInputWhenReady is renamed to sendTerminalInputWhenReady with visibility widened.
Sources/KeyboardShortcutSettings.swift Extracts ShortcutStroke.parse and parseKeyToken from the private file-store into public static methods, and wires customCommandStore into the settings namespace; refactor is clean.
Sources/KeyboardShortcutSettingsFileStore.swift Removes duplicated parseStroke/parseKeyToken/parseStoredShortcut private methods now that they live on StoredShortcut/ShortcutStroke; JSONCParser and ShortcutSettingsFileWatcher are un-privatised so CustomCommandStore can reuse them.
Sources/Workspace.swift Widens sendInputWhenReady to internal (sendTerminalInputWhenReady) and adds workingDirectory/startupEnvironment params to newTerminalSplit; changes are minimal and correct.
Sources/AppDelegate.swift Hooks custom command dispatch into performKeyEquivalent, correctly guarded behind an active-chord-prefix check; placement in the handler chain is appropriate.
tests_v2/test_custom_command_shortcut.py End-to-end regression test covering shortcut simulation, split creation, cwd propagation, and command output; relies on a fixed 0.5 s sleep for file-watcher reload which may be flaky under CI load.

Sequence Diagram

sequenceDiagram
    participant FS as Filesystem
    participant SW as ShortcutSettingsFileWatcher
    participant CS as CustomCommandStore
    participant KS as KeyboardShortcutSettings
    participant AD as AppDelegate
    participant TM as TabManager
    participant WS as Workspace

    FS->>SW: FSEvent (keybindings.json changed)
    SW->>CS: onChange callback
    CS->>CS: DispatchQueue.main.async reload()
    CS->>CS: JSONCParser.preprocess + JSONDecoder.decode
    CS->>CS: resolveBindings → [ResolvedBinding]

    Note over AD: User presses shortcut key
    AD->>KS: matchingCustomCommand(for: event)
    KS->>CS: matchingCommand(for: event)
    CS-->>KS: CustomCommandBinding?
    KS-->>AD: CustomCommandBinding?

    AD->>TM: openSurfaceAndRunCommand(target, cwd, command, id)
    TM->>TM: customCommandLaunchContext()
    TM->>TM: resolvedCustomCommandWorkingDirectory()
    TM->>TM: customCommandEnvironment()

    alt target = split_right / split_down
        TM->>WS: newTerminalSplit(from:, orientation:, workingDirectory:, startupEnvironment:)
    else target = new_surface
        TM->>WS: newTerminalSurface(inPane:, workingDirectory:, startupEnvironment:)
    else target = new_tab / new_workspace
        TM->>TM: addWorkspace(workingDirectory:, initialTerminalEnvironment:)
    end

    WS-->>TM: TerminalPanel
    TM->>WS: sendTerminalInputWhenReady(command + newline, to: panel)
Loading

Reviews (1): Last reviewed commit: "Implement custom command shortcut spawni..." | Re-trigger Greptile

Comment on lines +10 to +21
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
}
}

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.

Comment on lines +70 to +74
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

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.

try:
previous_keybindings = _write_keybindings_config(config)
time.sleep(0.5)

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 Fragile fixed-duration sleep for file-watcher reload

time.sleep(0.5) is the only guarantee that CustomCommandStore has re-read keybindings.json before the test simulates the shortcut. On slow CI runners or under heavy load this window can be missed, causing the shortcut to fire before the store has the binding and producing a spurious miss. Consider either polling for the binding to be active (via a CLI/socket query, if the store exposes a reload-notification) or, if no such hook exists, using a longer poll loop with a reasonable timeout instead of a bare sleep.

Comment thread Sources/TabManager.swift
Comment on lines +5040 to +5050
case .newTab, .newWorkspace:
let workspace = addWorkspace(
workingDirectory: resolvedWorkingDirectory,
initialTerminalEnvironment: environment,
select: true
)
guard let panel = workspace.focusedTerminalPanel else { return false }
workspace.sendTerminalInputWhenReady(input, to: panel)
scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command")
return true
}

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 newTab and newWorkspace targets are identical

Both cases call addWorkspace(...) with the same arguments, so a user who writes "target": "new_tab" gets exactly the same behaviour as "new_workspace". Given that cmux has a Tab = Workspace alias this may be intentional, but it means one of the two documented target values is misleading. If they are truly synonyms, consider removing newTab from CustomCommandTarget (or aliasing it in docs) to avoid user confusion. If there is a planned distinction (e.g. new_surface in the current workspace's focused pane vs. a brand-new workspace), the implementation should reflect it.

@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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/Workspace.swift (1)

953-979: ⚠️ Potential issue | 🟠 Major

Use a dedicated custom-command launcher instead of raw terminal input.

The new shortcut path calls this with trimmedCommand + "\n" from Sources/TabManager.swift, so configured commands run in the user’s interactive shell rather than via the required /bin/sh -c. It also relies on the initial cwd only; shell rc files can cd before this delayed injection runs, and the fixed 3s timeout can silently drop slow-start commands.

Suggested direction
-    func sendTerminalInputWhenReady(_ text: String, to panel: TerminalPanel) {
+    func sendTerminalInputWhenReady(_ text: String, to panel: TerminalPanel, timeout: TimeInterval = 3.0) {
         if panel.surface.surface != nil {
             panel.sendInput(text)
             return
         }
@@
-        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
+        DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
             guard !resolved else { return }
             resolved = true
             if let observer { NotificationCenter.default.removeObserver(observer) }
             NSLog("[CmuxConfig] surface not ready after 3s, dropping command (%d chars)", text.count)
         }
     }
+
+    func sendCustomCommandWhenReady(_ command: String, cwd: String?, to panel: TerminalPanel) {
+        let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedCommand.isEmpty else { return }
+
+        let shellCommand = "/bin/sh -c \(Self.shellSingleQuoted(trimmedCommand))"
+        let guardedCommand: String
+        if let cwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty {
+            guardedCommand = "cd \(Self.shellSingleQuoted(cwd)) && \(shellCommand)"
+        } else {
+            guardedCommand = shellCommand
+        }
+        sendTerminalInputWhenReady(guardedCommand + "\n", to: panel, timeout: 30.0)
+    }
+
+    private static func shellSingleQuoted(_ value: String) -> String {
+        "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
+    }

Then update Sources/TabManager.swift to call sendCustomCommandWhenReady(..., cwd: resolvedWorkingDirectory, to: panel) instead of passing raw trimmedCommand + "\n".

Based on learnings: In this repo’s shell session resume flow, always build resume commands with a guaranteed cwd guard so fresh shells and rc files cannot land outside the intended directory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 953 - 979, Replace the raw-injection
helper sendTerminalInputWhenReady with a new sendCustomCommandWhenReady(_
command: String, cwd: String, to panel: TerminalPanel) that still waits for
panel.surface readiness (reuse the observer pattern in
sendTerminalInputWhenReady) but instead of sending text injects a dedicated
custom-command launch API on the terminal (invoke the terminal’s
command-launcher rather than panel.sendInput). Construct the launched command to
include a cwd-guard (e.g., prefix the shell invocation with a guaranteed cd to
the provided cwd) and run via the proper shell execution path (use /bin/sh -c or
the repo’s launcher contract) so rc files cannot escape the intended directory;
remove the fragile "trimmedCommand + \"\n\"" raw-input usage and the fixed 3s
drop behavior, and update TabManager to call sendCustomCommandWhenReady(...,
cwd: resolvedWorkingDirectory, to: panel) instead of sendTerminalInputWhenReady
with the newline-injected string.
🧹 Nitpick comments (3)
Sources/KeybindingsConfigFile.swift (2)

36-53: Tighten absolute-path validation and consider ~ expansion.

A couple of edge cases worth addressing in the default branch:

  • "/" alone passes hasPrefix("/") and becomes .absolutePath("/") — probably not what users intend. Consider requiring the path to contain at least one additional character (and rejecting empty strings up front).
  • The PR spec is "absolute path", but users commonly write ~/projects/foo in JSON config. Either document that ~ is not supported, or expand it here (e.g., NSString(string: raw).expandingTildeInPath) before storing. Silent rejection of ~-prefixed paths with only the generic error message will confuse users.

Also, the error message mentions the valid sentinel values but not the offending input; including rawValue in debugDescription would make config errors much easier to diagnose.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/KeybindingsConfigFile.swift` around lines 36 - 53, The decoder init
(init(from decoder: Decoder)) currently treats any string starting with "/" as a
valid .absolutePath; change it to first reject empty rawValue and rawValue ==
"/" (require at least one more path character), expand leading "~" by calling
NSString(string: rawValue).expandingTildeInPath before validation (or explicitly
reject "~" if you choose not to support it), then validate the expanded string
starts with "/" and is longer than 1 before assigning .absolutePath(...); when
throwing DecodingError.dataCorruptedError include the offending rawValue in the
debugDescription to aid diagnostics (refer to rawValue, .absolutePath(rawValue),
container, and DecodingError.dataCorruptedError).

4-7: Use camelCase with CodingKeys instead of snake_case stored property.

var custom_commands violates Swift naming conventions. Prefer a customCommands property mapped via CodingKeys so the JSON key stays custom_commands while the Swift API remains idiomatic.

♻️ Proposed refactor
     struct Schema: Codable {
         var version: Int?
-        var custom_commands: [CustomCommandBinding]?
+        var customCommands: [CustomCommandBinding]?
+
+        private enum CodingKeys: String, CodingKey {
+            case version
+            case customCommands = "custom_commands"
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/KeybindingsConfigFile.swift` around lines 4 - 7, The Schema struct
uses a snake_case stored property `custom_commands`; rename it to
`customCommands` and add a CodingKeys enum to map the JSON key to the new
property name (e.g., enum CodingKeys: String, CodingKey { case version,
customCommands = "custom_commands" }) so Codable still decodes/encodes the
`custom_commands` JSON key; update any references to `Schema.custom_commands` to
`Schema.customCommands` throughout the codebase.
Sources/KeyboardShortcutSettingsFileStore.swift (1)

1530-1530: No module-scope name collisions found after visibility widening.

JSONCParser and ShortcutSettingsFileWatcher are now module-internal. Verification confirms:

  1. No duplicate type declarations exist (each is unique in the module).
  2. ShortcutSettingsFileWatcher is actively used across multiple stores (KeyboardShortcutSettingsFileStore and CustomCommandStore), confirming broader scope. Consider renaming to CmuxConfigFileWatcher or SettingsFileWatcher to better reflect its role monitoring multiple configuration file types.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/KeyboardShortcutSettingsFileStore.swift` at line 1530, The symbol
ShortcutSettingsFileWatcher has been widened to module scope but its name no
longer reflects its multi-store role; rename ShortcutSettingsFileWatcher (and
any matching file/class declarations) to a clearer name such as
CmuxConfigFileWatcher or SettingsFileWatcher, update all references (e.g., in
KeyboardShortcutSettingsFileStore and CustomCommandStore and any other
consumers), and ensure the JSONCParser symbol remains unchanged; adjust
initializer signatures and documentation comments if present so callers compile
after the rename and run-time behavior is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/CustomCommandStore.swift`:
- Around line 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.

In `@Sources/TabManager.swift`:
- Around line 5001-5050: Add DEBUG-only dlog() calls surrounding the launch
success/failure branches for the switch on target: for the
.splitRight/.splitDown path (around workspace.newTerminalSplit and before
returning) and for .newSurface (around
bonsplitController.focusedPaneId/newTerminalSurface) and .newTab/.newWorkspace
(around addWorkspace and after obtaining workspace.focusedTerminalPanel). Wrap
each dlog() in `#if` DEBUG / `#endif`, log only metadata such as target enum value,
workspace.id, paneId or panel.id, and a short status string ("launch_success" /
"launch_failed"), and explicitly do NOT log the command/input contents or
environment; place the success log before
scheduleInitialWorkspaceGitMetadataRefreshIfPossible and the failure log where
the guard returns false.
- Line 4999: The current code sends trimmedCommand directly to the terminal
(creating input = trimmedCommand + "\n") which bypasses /bin/sh -c semantics;
update TabManager to wrap the command in a /bin/sh -c invocation and properly
single-quote the command string: add a nonisolated helper
TabManager.shellSingleQuoted(_:) that escapes single quotes, then construct
input as "/bin/sh -c " + TabManager.shellSingleQuoted(trimmedCommand) + "\n" (or
equivalent string concatenation) so the terminal receives the command executed
via /bin/sh -c with correct quoting.
- Around line 5040-5048: Split the combined case so .newTab and .newWorkspace
are handled separately: keep the current behavior for .newWorkspace, but for
.newTab create the workspace via addWorkspace and explicitly disable welcome
injection for command-created workspaces (e.g. set the workspace flag that
prevents auto welcome or avoid calling autoWelcomeIfNeeded on the new workspace)
before sending input with workspace.sendTerminalInputWhenReady(...); still call
scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id,
panelId: panel.id, reason: "custom_command") for the workspace as before.

In `@Sources/Workspace.swift`:
- Around line 8840-8843: The code validates overrideWorkingDirectory by trimming
whitespace but then returns the original untrimmed string; update the return to
use the trimmed value (e.g., compute let trimmed =
overrideWorkingDirectory.trimmingCharacters(in: .whitespacesAndNewlines) and
return trimmed) so the function returns a normalized/validated working directory
when overrideWorkingDirectory is present.

In `@tests_v2/test_custom_command_shortcut.py`:
- Around line 90-104: The test currently writes KEYBINDINGS_PATH in-place which
can leave a partially-written file; change _write_keybindings_config to write
JSON to a same-directory temporary file (use KEYBINDINGS_PATH.parent and a
unique temp filename) and then atomically replace the target with
os.replace(temp_path, KEYBINDINGS_PATH) after the write is complete; likewise
update _restore_keybindings_config to restore by writing the saved bytes to a
same-directory temp file and os.replace it into KEYBINDINGS_PATH (or unlink
KEYBINDINGS_PATH if previous is None, handling FileNotFoundError), ensuring
UTF-8 encoding when creating the temp file and preserving previous bytes in the
saved variable.

---

Outside diff comments:
In `@Sources/Workspace.swift`:
- Around line 953-979: Replace the raw-injection helper
sendTerminalInputWhenReady with a new sendCustomCommandWhenReady(_ command:
String, cwd: String, to panel: TerminalPanel) that still waits for panel.surface
readiness (reuse the observer pattern in sendTerminalInputWhenReady) but instead
of sending text injects a dedicated custom-command launch API on the terminal
(invoke the terminal’s command-launcher rather than panel.sendInput). Construct
the launched command to include a cwd-guard (e.g., prefix the shell invocation
with a guaranteed cd to the provided cwd) and run via the proper shell execution
path (use /bin/sh -c or the repo’s launcher contract) so rc files cannot escape
the intended directory; remove the fragile "trimmedCommand + \"\n\"" raw-input
usage and the fixed 3s drop behavior, and update TabManager to call
sendCustomCommandWhenReady(..., cwd: resolvedWorkingDirectory, to: panel)
instead of sendTerminalInputWhenReady with the newline-injected string.

---

Nitpick comments:
In `@Sources/KeybindingsConfigFile.swift`:
- Around line 36-53: The decoder init (init(from decoder: Decoder)) currently
treats any string starting with "/" as a valid .absolutePath; change it to first
reject empty rawValue and rawValue == "/" (require at least one more path
character), expand leading "~" by calling NSString(string:
rawValue).expandingTildeInPath before validation (or explicitly reject "~" if
you choose not to support it), then validate the expanded string starts with "/"
and is longer than 1 before assigning .absolutePath(...); when throwing
DecodingError.dataCorruptedError include the offending rawValue in the
debugDescription to aid diagnostics (refer to rawValue, .absolutePath(rawValue),
container, and DecodingError.dataCorruptedError).
- Around line 4-7: The Schema struct uses a snake_case stored property
`custom_commands`; rename it to `customCommands` and add a CodingKeys enum to
map the JSON key to the new property name (e.g., enum CodingKeys: String,
CodingKey { case version, customCommands = "custom_commands" }) so Codable still
decodes/encodes the `custom_commands` JSON key; update any references to
`Schema.custom_commands` to `Schema.customCommands` throughout the codebase.

In `@Sources/KeyboardShortcutSettingsFileStore.swift`:
- Line 1530: The symbol ShortcutSettingsFileWatcher has been widened to module
scope but its name no longer reflects its multi-store role; rename
ShortcutSettingsFileWatcher (and any matching file/class declarations) to a
clearer name such as CmuxConfigFileWatcher or SettingsFileWatcher, update all
references (e.g., in KeyboardShortcutSettingsFileStore and CustomCommandStore
and any other consumers), and ensure the JSONCParser symbol remains unchanged;
adjust initializer signatures and documentation comments if present so callers
compile after the rename and run-time behavior is preserved.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7005ff6f-825c-47b4-a7e6-5b7cbb351a1b

📥 Commits

Reviewing files that changed from the base of the PR and between e976893 and 91d4d59.

📒 Files selected for processing (9)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/AppDelegate.swift
  • Sources/CustomCommandStore.swift
  • Sources/KeybindingsConfigFile.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • tests_v2/test_custom_command_shortcut.py

Comment on lines +53 to +96
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
}
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

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.

Comment thread Sources/TabManager.swift
workspaceDirectory: workspaceDirectory,
paneDirectory: paneDirectory
)
let input = trimmedCommand + "\n"

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

Run custom commands through /bin/sh -c, not the interactive shell.

Line 4999 sends the configured command directly to whatever shell the terminal starts. That misses the required /bin/sh -c semantics and lets user shell aliases/functions/rc state change behavior.

🐛 Proposed fix
-        let input = trimmedCommand + "\n"
+        let input = "/bin/sh -c \(Self.shellSingleQuoted(trimmedCommand))\n"

Add a small quoting helper on TabManager:

private nonisolated static func shellSingleQuoted(_ value: String) -> String {
    "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` at line 4999, The current code sends trimmedCommand
directly to the terminal (creating input = trimmedCommand + "\n") which bypasses
/bin/sh -c semantics; update TabManager to wrap the command in a /bin/sh -c
invocation and properly single-quote the command string: add a nonisolated
helper TabManager.shellSingleQuoted(_:) that escapes single quotes, then
construct input as "/bin/sh -c " + TabManager.shellSingleQuoted(trimmedCommand)
+ "\n" (or equivalent string concatenation) so the terminal receives the command
executed via /bin/sh -c with correct quoting.

Comment thread Sources/TabManager.swift
Comment on lines +5001 to +5050
switch target {
case .splitRight, .splitDown:
guard let workspace = launchContext?.workspace,
let sourcePanelId = launchContext?.sourcePanelId else {
return false
}
workspace.clearSplitZoom()
let orientation: SplitOrientation = (target == .splitRight) ? .horizontal : .vertical
guard let panel = workspace.newTerminalSplit(
from: sourcePanelId,
orientation: orientation,
insertFirst: false,
focus: true,
workingDirectory: resolvedWorkingDirectory,
startupEnvironment: environment
) else {
return false
}
workspace.sendTerminalInputWhenReady(input, to: panel)
scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command")
return true

case .newSurface:
guard let workspace = launchContext?.workspace else { return false }
let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first
guard let paneId else { return false }
workspace.clearSplitZoom()
guard let panel = workspace.newTerminalSurface(
inPane: paneId,
focus: true,
workingDirectory: resolvedWorkingDirectory,
startupEnvironment: environment
) else {
return false
}
workspace.sendTerminalInputWhenReady(input, to: panel)
scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command")
return true

case .newTab, .newWorkspace:
let workspace = addWorkspace(
workingDirectory: resolvedWorkingDirectory,
initialTerminalEnvironment: environment,
select: true
)
guard let panel = workspace.focusedTerminalPanel else { return false }
workspace.sendTerminalInputWhenReady(input, to: panel)
scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command")
return 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.

⚠️ Potential issue | 🟡 Minor

Add DEBUG-only launch logging without command contents.

This path creates splits/surfaces/workspaces but has no dlog() event. Add metadata-only logs around launch success/failure; avoid logging command because it can contain secrets. As per coding guidelines, all debug events for keys, focus, splits, and tabs must be logged using dlog() inside #if DEBUG / #endif.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5001 - 5050, Add DEBUG-only dlog()
calls surrounding the launch success/failure branches for the switch on target:
for the .splitRight/.splitDown path (around workspace.newTerminalSplit and
before returning) and for .newSurface (around
bonsplitController.focusedPaneId/newTerminalSurface) and .newTab/.newWorkspace
(around addWorkspace and after obtaining workspace.focusedTerminalPanel). Wrap
each dlog() in `#if` DEBUG / `#endif`, log only metadata such as target enum value,
workspace.id, paneId or panel.id, and a short status string ("launch_success" /
"launch_failed"), and explicitly do NOT log the command/input contents or
environment; place the success log before
scheduleInitialWorkspaceGitMetadataRefreshIfPossible and the failure log where
the guard returns false.

Comment thread Sources/TabManager.swift
Comment on lines +5040 to +5048
case .newTab, .newWorkspace:
let workspace = addWorkspace(
workingDirectory: resolvedWorkingDirectory,
initialTerminalEnvironment: environment,
select: true
)
guard let panel = workspace.focusedTerminalPanel else { return false }
workspace.sendTerminalInputWhenReady(input, to: panel)
scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command")

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

Keep new_tab distinct and disable welcome injection for command workspaces.

case .newTab, .newWorkspace currently makes both targets create a workspace. It also leaves autoWelcomeIfNeeded enabled, so first-run cmux welcome can be sent into the same terminal as the custom command.

🐛 Proposed direction
-        case .newTab, .newWorkspace:
+        case .newTab:
+            guard let workspace = launchContext?.workspace else { return false }
+            let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first
+            guard let paneId else { return false }
+            workspace.clearSplitZoom()
+            guard let panel = workspace.newTerminalSurface(
+                inPane: paneId,
+                focus: true,
+                workingDirectory: resolvedWorkingDirectory,
+                startupEnvironment: environment
+            ) else {
+                return false
+            }
+            workspace.sendTerminalInputWhenReady(input, to: panel)
+            scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command")
+            return true
+
+        case .newWorkspace:
             let workspace = addWorkspace(
                 workingDirectory: resolvedWorkingDirectory,
                 initialTerminalEnvironment: environment,
-                select: true
+                select: true,
+                autoWelcomeIfNeeded: false
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5040 - 5048, Split the combined case
so .newTab and .newWorkspace are handled separately: keep the current behavior
for .newWorkspace, but for .newTab create the workspace via addWorkspace and
explicitly disable welcome injection for command-created workspaces (e.g. set
the workspace flag that prevents auto welcome or avoid calling
autoWelcomeIfNeeded on the new workspace) before sending input with
workspace.sendTerminalInputWhenReady(...); still call
scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id,
panelId: panel.id, reason: "custom_command") for the workspace as before.

Comment thread Sources/Workspace.swift
Comment on lines +8840 to +8843
if let overrideWorkingDirectory,
!overrideWorkingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return overrideWorkingDirectory
}

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

Return the normalized override cwd.

Line 8841 validates the trimmed value, but Line 8842 returns the original string. A JSON cwd with accidental surrounding whitespace/newlines will pass validation and then fail as an invalid working directory.

Suggested fix
-            if let overrideWorkingDirectory,
-               !overrideWorkingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
-                return overrideWorkingDirectory
+            if let overrideWorkingDirectory {
+                let trimmedOverride = overrideWorkingDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
+                if !trimmedOverride.isEmpty {
+                    return trimmedOverride
+                }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 8840 - 8843, The code validates
overrideWorkingDirectory by trimming whitespace but then returns the original
untrimmed string; update the return to use the trimmed value (e.g., compute let
trimmed = overrideWorkingDirectory.trimmingCharacters(in:
.whitespacesAndNewlines) and return trimmed) so the function returns a
normalized/validated working directory when overrideWorkingDirectory is present.

Comment on lines +90 to +104
def _write_keybindings_config(payload: dict) -> bytes | None:
KEYBINDINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
previous = KEYBINDINGS_PATH.read_bytes() if KEYBINDINGS_PATH.exists() else None
KEYBINDINGS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return previous


def _restore_keybindings_config(previous: bytes | None) -> None:
if previous is None:
try:
KEYBINDINGS_PATH.unlink()
except FileNotFoundError:
pass
return
KEYBINDINGS_PATH.write_bytes(previous)

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

Write and restore keybindings atomically.

Line 93 truncates the live-reloaded config in place, so the watcher can parse an empty/partial file and clear the binding before Line 160 simulates the shortcut. Use a same-directory temp file plus os.replace for both test setup and restore.

Proposed fix
+def _atomic_write_keybindings(contents: bytes) -> None:
+    KEYBINDINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
+    fd, temp_name = tempfile.mkstemp(
+        prefix=f".{KEYBINDINGS_PATH.name}.",
+        suffix=".tmp",
+        dir=str(KEYBINDINGS_PATH.parent),
+    )
+    temp_path = Path(temp_name)
+    try:
+        with os.fdopen(fd, "wb") as handle:
+            handle.write(contents)
+            handle.flush()
+            os.fsync(handle.fileno())
+        os.replace(temp_path, KEYBINDINGS_PATH)
+    finally:
+        try:
+            temp_path.unlink()
+        except FileNotFoundError:
+            pass
+
+
 def _write_keybindings_config(payload: dict) -> bytes | None:
-    KEYBINDINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
     previous = KEYBINDINGS_PATH.read_bytes() if KEYBINDINGS_PATH.exists() else None
-    KEYBINDINGS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
+    _atomic_write_keybindings(json.dumps(payload, indent=2).encode("utf-8"))
     return previous
 
 
 def _restore_keybindings_config(previous: bytes | None) -> None:
@@
         except FileNotFoundError:
             pass
         return
-    KEYBINDINGS_PATH.write_bytes(previous)
+    _atomic_write_keybindings(previous)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests_v2/test_custom_command_shortcut.py` around lines 90 - 104, The test
currently writes KEYBINDINGS_PATH in-place which can leave a partially-written
file; change _write_keybindings_config to write JSON to a same-directory
temporary file (use KEYBINDINGS_PATH.parent and a unique temp filename) and then
atomically replace the target with os.replace(temp_path, KEYBINDINGS_PATH) after
the write is complete; likewise update _restore_keybindings_config to restore by
writing the saved bytes to a same-directory temp file and os.replace it into
KEYBINDINGS_PATH (or unlink KEYBINDINGS_PATH if previous is None, handling
FileNotFoundError), ensuring UTF-8 encoding when creating the temp file and
preserving previous bytes in the saved variable.

@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 1 potential issue.

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 91d4d59. Configure here.

Comment thread Sources/AppDelegate.swift
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.

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests_v2/test_custom_command_shortcut.py">

<violation number="1" location="tests_v2/test_custom_command_shortcut.py:139">
P2: Capture the existing keybindings backup before writing, so a write failure cannot lose the original file during cleanup.</violation>
</file>

<file name="Sources/CustomCommandStore.swift">

<violation number="1" location="Sources/CustomCommandStore.swift:41">
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.</violation>
</file>

<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:8842">
P2: The trimmed value is validated but the original (potentially whitespace-padded) `overrideWorkingDirectory` is returned. Return the trimmed string instead to avoid passing a path with leading/trailing whitespace as a working directory.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


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

baseline_workspace = client.current_workspace()
created_workspace = ""
try:
previous_keybindings = _write_keybindings_config(config)

@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.

P2: Capture the existing keybindings backup before writing, so a write failure cannot lose the original file during cleanup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests_v2/test_custom_command_shortcut.py, line 139:

<comment>Capture the existing keybindings backup before writing, so a write failure cannot lose the original file during cleanup.</comment>

<file context>
@@ -0,0 +1,212 @@
+        baseline_workspace = client.current_workspace()
+        created_workspace = ""
+        try:
+            previous_keybindings = _write_keybindings_config(config)
+            time.sleep(0.5)
+
</file context>
Fix with Cubic

Comment thread Sources/Workspace.swift
let splitWorkingDirectory: String? = {
if let overrideWorkingDirectory,
!overrideWorkingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return overrideWorkingDirectory

@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.

P2: The trimmed value is validated but the original (potentially whitespace-padded) overrideWorkingDirectory is returned. Return the trimmed string instead to avoid passing a path with leading/trailing whitespace as a working directory.

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

<comment>The trimmed value is validated but the original (potentially whitespace-padded) `overrideWorkingDirectory` is returned. Return the trimmed string instead to avoid passing a path with leading/trailing whitespace as a working directory.</comment>

<file context>
@@ -8835,6 +8837,10 @@ final class Workspace: Identifiable, ObservableObject {
         let splitWorkingDirectory: String? = {
+            if let overrideWorkingDirectory,
+               !overrideWorkingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+                return overrideWorkingDirectory
+            }
             if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
</file context>
Fix with Cubic

…-3019-preset-command-shortcut

# Conflicts:
#	GhosttyTabs.xcodeproj/project.pbxproj
@mturley

mturley commented Jul 17, 2026

Copy link
Copy Markdown

This functionality is already mostly available via command actions in cmux.json, see my reply to your issue here: #3019 (comment)

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 97adb983 Deployed Apr 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keyboard shortcut to open a new pane/tab running a preset command (e.g. claude, codex)

4 participants