Skip to content

Add leader-key (multi-key sequence) shortcuts — foundation (#431)#438

Draft
cloud-on-prem wants to merge 1 commit into
supabitapp:mainfrom
cloud-on-prem:leader-key-sequence-shortcuts
Draft

Add leader-key (multi-key sequence) shortcuts — foundation (#431)#438
cloud-on-prem wants to merge 1 commit into
supabitapp:mainfrom
cloud-on-prem:leader-key-sequence-shortcuts

Conversation

@cloud-on-prem

Copy link
Copy Markdown

Summary

Adds configurable leader-key + multi-key sequence shortcuts (e.g. ⌘K then t n) as an additive, opt-in layer alongside the existing single-chord shortcutOverrides. Closes the foundation for #431.

Sequences are lowered to Ghostty's native sequence-keybind engine (leader>k>k=action) and injected at launch through the existing GhosttyCLI.argvghostty_init path — no new input layer.

Scope of this PR (foundation, v1)

This is deliberately a Swift-only foundation PR. While investigating, I confirmed a load-bearing constraint:

A leader sequence lowered to Ghostty's engine can only trigger Ghostty built-in actions that round-trip to the host (rt_app.performAction). text:/esc:/csi: keybind actions route to the pty (child process), not the host, and apprt.Action.Key has a fixed member set with no generic host passthrough.

So the issue's menu-only app actions (Create/Archive Worktree, Toggle Sidebar, Open PR, Open Repo, Reveal in Finder, Next/Prev Worktree) cannot be bound to a sequence without an out-of-tree Ghostty source patch. Rather than ship that patch in the first PR, this PR builds the entire mechanism and binds the actions that work natively today, with a clean, additive seam for the rest.

In this PR

  • Configurable leader chord (default ⌘K, verified free in-app), persisted in a new additive GlobalSettings.leaderKey.
  • Multi-key sequences → Ghostty leader>k>k=action keybind lowering.
  • Settings → Shortcuts UI: leader row + sequence editor + target picker.
  • Prefix/duplicate/leader/reserved/single-chord conflict warnings (inline, non-blocking).
  • Backward-compatible, zero-migration persistence — existing single chords untouched.
  • A bound leader>escape=end_key_sequence cancel; unmatched sequences pass through to the terminal natively.

Bindable actions in v1 (Ghostty host-routable built-ins): New/Close Tab, Jump-to-Worktree-N (goto_tab:N), Move Tab, Toggle Command Palette, and the five split actions. Menu-only app actions are structurally unrepresentable in the target picker so they can't silently no-op.

Deferred (follow-ups)

  • Menu-only app-action targets — via an out-of-tree Ghostty patch adding a generic host-routable action; the stored LeaderActionTarget is an extensible sum type (.ghostty(...) today, .appShortcut(...) later) and the codec lossy-drops unknown kinds, so the follow-up is zero-migration.
  • which-key discoverability overlay (a documented extension seam only).
  • No app-side sequence timeout — Ghostty has none (no config, no mid-sequence host callback), so unmatched sequences rely on native flush + the explicit escape cancel.

Implementation

Area File
Model types SupacodeSettingsShared/App/LeaderKeySequence.swift (new)
Persistence SupacodeSettingsShared/Models/GlobalSettings.swift
Keybind lowering + launch wiring SupacodeSettingsShared/App/AppShortcuts.swift, supacode/App/supacodeApp.swift
Conflict validator (pure, trie-based) SupacodeSettingsShared/App/LeaderKeyConflictValidator.swift (new)
Reducer arms SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Settings UI supacode/Features/Settings/Views/KeyboardShortcutsSettingsView.swift, SequenceKeyRecorder.swift (new)
Tests (37 cases) supacodeTests/ (model codec/lossy, lowering strings, validator, reducer TestStore, backward-compat)

Testing

  • 37 unit tests covering: Codable round-trips + lossy-drop, backward-compat decode (leaderKey == nil for pre-feature settings), lowering strings (incl. the leader>escape=end_key_sequence cancel), all conflict cases, and the reducer arms via TestStore.
  • SwiftLint --strict: 0 violations across all changed files.

⚠️ Not yet build-verified — remaining gate

I could not build/run the app or execute the test bundle in my environment: Frameworks/GhosttyKit.xcframework is absent and a from-source zig build fails here with libSystem link errors (environment/toolchain, unrelated to this change). The code is verified by swiftc -typecheck (against real types where possible) + SwiftLint + the authored test suite, but the full-graph compile and runtime behavior have not been exercised. Please run, before merge:

  • make build-app and make test (executes the 5 new test classes) in a build-capable environment / CI
  • Bind a sequence to a built-in (e.g. goto_tab, a split) and confirm it fires while Supacode is focused
  • Multi-step sequence waits for the 2nd key without leaking the first to the terminal
  • After the leader, an unmatched key flushes to the shell and the prompt stays usable (and doesn't strand a TUI like less)
  • leader then escape cancels cleanly
  • A single chord and a sequence on the same action both fire independently
  • Leader binds are injected at launch, so changes apply after relaunch (expected; surfaced in the UI)

🤖 Generated using: 🎮 rp1.run

Add configurable leader-key + multi-key sequence shortcuts that lower to
Ghostty native sequence keybinds (leader>k>k=action), as an additive,
opt-in layer alongside the existing single-chord shortcutOverrides. Refs supabitapp#431.

v1 scope is foundation-only (Swift, no Ghostty source patch): a sequence may
target only Ghostty host-routable built-in actions (new/close/goto/move tab,
toggle command palette, splits). Menu-only app actions cannot be reached from
a Ghostty sequence without a source patch (verified), so they are deferred via
the additive LeaderActionTarget seam (.ghostty in v1, .appShortcut later). The
which-key discoverability overlay is also deferred to a follow-up.

- LeaderKeySequence.swift: model types (SequenceKeyStroke, GhosttyLeaderAction,
  LeaderActionTarget, LeaderKeySequence, LeaderKeyConfig)
- GlobalSettings.leaderKey: additive optional persistence (decodeIfPresent ?? nil,
  lossy element decode) - zero migration, single chords untouched
- AppShortcuts.leaderKeyGhosttyKeybindArguments: lowering + GhosttyCLI.argv wiring;
  emits a leader>escape=end_key_sequence cancel bind; returns [] when no leader set
- LeaderKeyConflictValidator: pure trie-based prefix / duplicate / leader /
  reserved / single-chord conflict detection, surfaced inline in Settings
- SettingsFeature: reducer arms (set/clear leader, add/edit/remove sequence) via
  @shared(.settingsFile)
- Settings UI: leader row + sequence editor + GhosttyLeaderAction-only target picker
- Tests: 37 cases (model codec/lossy-drop, lowering strings, validator, reducer
  TestStore, backward-compat decode)

Notes: Ghostty has no sequence timeout (none exists in Ghostty), so unmatched
sequences pass through natively rather than timing out. Leader binds are injected
at launch and apply after relaunch.

Generated with AI

Co-Authored-By: rp1 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant