Summary
Add support for binding arbitrary key combos to shell commands that run in the background using the focused pane's CWD. Neither cmux nor Ghostty currently supports this — Ghostty keybinds only map to built-in terminal actions (no exec: or shell: action type).
Example use case: shift+alt+p runs plannotator view in the current pane's directory without disturbing the terminal.
JSON Config Format
Extend ~/.config/cmux/keybindings.json with a custom_commands array:
{
"version": 1,
"keybindings": { "toggleSidebar": "cmd+b" },
"custom_commands": [
{
"id": "plannotator-view",
"shortcut": "shift+alt+p",
"command": "plannotator view",
"label": "Open Plannotator",
"cwd": "pane"
}
]
}
Fields: id (required, unique), shortcut (required, same format as built-in), command (required, shell string), label (optional), cwd (optional: "pane" default, "workspace", or absolute path).
Implementation Plan
Phase 1: Data Model
File: Sources/KeybindingsConfigFile.swift
- Add
CustomCommandBinding struct (Codable, Equatable, Identifiable) with fields: id, shortcut, command, label?, cwd?
- Add
custom_commands: [CustomCommandBinding]? to Schema (optional for backward compat — no version bump needed)
- Add computed
resolvedShortcut property that calls existing stringToShortcut()
Phase 2: In-Memory Store + Executor
New file: Sources/CustomCommandStore.swift
Singleton holding parsed custom commands + resolved shortcuts for fast matching.
reload() — loads from config file, parses shortcuts
matchingCommand(for: NSEvent) -> CustomCommandBinding? — reuses same flag normalization as AppDelegate.matchShortcut (line 10462-10464): strip numericPad/function/capsLock, compare flags, then match key character
addCommand() / removeCommand() — mutate in-memory + persist to config file
New file: Sources/ShellCommandExecutor.swift
Fire-and-forget executor:
- Runs
/bin/sh -c <command> on DispatchQueue.global(qos: .userInitiated) (off-main per socket threading policy)
- Sets
currentDirectoryURL based on cwd mode:
"pane" → workspace.panelDirectories[focusedPanelId]
"workspace" → workspace.currentDirectory
- absolute path → as-is
- fallback → home directory
- Exposes context as env vars:
CMUX_PANE_CWD, CMUX_WORKSPACE_CWD, CMUX_PANEL_ID, CMUX_WORKSPACE_ID
- stdout/stderr →
/dev/null (user can redirect in their command)
- Errors logged via
dlog() in DEBUG builds only
Phase 3: Key Dispatch Integration
File: Sources/AppDelegate.swift
Insert custom command check at line ~9257 — after fast-path returns (empty flags, browser omnibar bypass) but before built-in action matching. This lets custom commands override built-in shortcuts.
if let cmd = CustomCommandStore.shared.matchingCommand(for: event) {
#if DEBUG
dlog("shortcut.customCommand id=\(cmd.id) cmd=\(cmd.command)")
#endif
ShellCommandExecutor.execute(binding: cmd, context: customCommandContext())
return true
}
Add customCommandContext() helper that reads tabManager?.selectedWorkspace to get focusedPanelId, panelDirectories, and currentDirectory.
Phase 4: Socket Commands
File: Sources/TerminalController.swift
Add v2 socket commands following existing keybindings.* pattern:
custom_commands.list — returns all custom commands
custom_commands.add — add/update a custom command (params: id, shortcut, command, label?, cwd?)
custom_commands.remove — remove by id
custom_commands.reload — reload from config file
Phase 5: CLI Commands
File: CLI/cmux.swift
Add custom-commands subcommand following the keybindings pattern:
cmux custom-commands list [--json]
cmux custom-commands add <id> <shortcut> <command> [--label X] [--cwd pane|workspace|/path]
cmux custom-commands remove <id>
cmux custom-commands reload
Key Files
| File |
Changes |
Sources/KeybindingsConfigFile.swift |
Add CustomCommandBinding struct, extend Schema |
Sources/CustomCommandStore.swift |
New — singleton store + shortcut matching |
Sources/ShellCommandExecutor.swift |
New — background /bin/sh -c executor |
Sources/AppDelegate.swift |
Wire into handleCustomShortcut at ~line 9257, add context helper |
Sources/TerminalController.swift |
Add custom_commands.* socket commands |
CLI/cmux.swift |
Add custom-commands CLI subcommand |
Design Decisions
- Custom commands checked before built-in actions — users who bind
cmd+n to a custom command know what they're doing
- No settings UI for v1 — JSON + CLI only; UI can come later reading from
CustomCommandStore
- No version bump —
custom_commands is optional in Schema, existing v1 files decode fine
- No file watching — reload via CLI/socket; matches current keybindings behavior
- Invalid shortcuts in config silently skipped with
dlog() warning
Verification
- Create
~/.config/cmux/keybindings.json with a test custom command
- Run
cmux custom-commands reload (or restart app)
- Press the bound shortcut in a terminal pane
- Verify the command ran in the background at the correct CWD
- Verify built-in shortcuts still work
- Verify the shortcut doesn't reach the terminal (event consumed)
- Test via CLI:
cmux custom-commands list, add, remove
Summary
Add support for binding arbitrary key combos to shell commands that run in the background using the focused pane's CWD. Neither cmux nor Ghostty currently supports this — Ghostty keybinds only map to built-in terminal actions (no
exec:orshell:action type).Example use case:
shift+alt+prunsplannotator viewin the current pane's directory without disturbing the terminal.JSON Config Format
Extend
~/.config/cmux/keybindings.jsonwith acustom_commandsarray:{ "version": 1, "keybindings": { "toggleSidebar": "cmd+b" }, "custom_commands": [ { "id": "plannotator-view", "shortcut": "shift+alt+p", "command": "plannotator view", "label": "Open Plannotator", "cwd": "pane" } ] }Fields:
id(required, unique),shortcut(required, same format as built-in),command(required, shell string),label(optional),cwd(optional:"pane"default,"workspace", or absolute path).Implementation Plan
Phase 1: Data Model
File:
Sources/KeybindingsConfigFile.swiftCustomCommandBindingstruct (Codable, Equatable, Identifiable) with fields: id, shortcut, command, label?, cwd?custom_commands: [CustomCommandBinding]?toSchema(optional for backward compat — no version bump needed)resolvedShortcutproperty that calls existingstringToShortcut()Phase 2: In-Memory Store + Executor
New file:
Sources/CustomCommandStore.swiftSingleton holding parsed custom commands + resolved shortcuts for fast matching.
reload()— loads from config file, parses shortcutsmatchingCommand(for: NSEvent) -> CustomCommandBinding?— reuses same flag normalization asAppDelegate.matchShortcut(line 10462-10464): strip numericPad/function/capsLock, compare flags, then match key characteraddCommand()/removeCommand()— mutate in-memory + persist to config fileNew file:
Sources/ShellCommandExecutor.swiftFire-and-forget executor:
/bin/sh -c <command>onDispatchQueue.global(qos: .userInitiated)(off-main per socket threading policy)currentDirectoryURLbased on cwd mode:"pane"→workspace.panelDirectories[focusedPanelId]"workspace"→workspace.currentDirectoryCMUX_PANE_CWD,CMUX_WORKSPACE_CWD,CMUX_PANEL_ID,CMUX_WORKSPACE_ID/dev/null(user can redirect in their command)dlog()in DEBUG builds onlyPhase 3: Key Dispatch Integration
File:
Sources/AppDelegate.swiftInsert custom command check at line ~9257 — after fast-path returns (empty flags, browser omnibar bypass) but before built-in action matching. This lets custom commands override built-in shortcuts.
Add
customCommandContext()helper that readstabManager?.selectedWorkspaceto getfocusedPanelId,panelDirectories, andcurrentDirectory.Phase 4: Socket Commands
File:
Sources/TerminalController.swiftAdd v2 socket commands following existing
keybindings.*pattern:custom_commands.list— returns all custom commandscustom_commands.add— add/update a custom command (params: id, shortcut, command, label?, cwd?)custom_commands.remove— remove by idcustom_commands.reload— reload from config filePhase 5: CLI Commands
File:
CLI/cmux.swiftAdd
custom-commandssubcommand following thekeybindingspattern:Key Files
Sources/KeybindingsConfigFile.swiftCustomCommandBindingstruct, extendSchemaSources/CustomCommandStore.swiftSources/ShellCommandExecutor.swift/bin/sh -cexecutorSources/AppDelegate.swifthandleCustomShortcutat ~line 9257, add context helperSources/TerminalController.swiftcustom_commands.*socket commandsCLI/cmux.swiftcustom-commandsCLI subcommandDesign Decisions
cmd+nto a custom command know what they're doingCustomCommandStorecustom_commandsis optional in Schema, existing v1 files decode finedlog()warningVerification
~/.config/cmux/keybindings.jsonwith a test custom commandcmux custom-commands reload(or restart app)cmux custom-commands list,add,remove