From 641d8ef0996188ba92ec364b08611d0f570e0eb6 Mon Sep 17 00:00:00 2001 From: Prem Pillai Date: Sun, 21 Jun 2026 16:33:17 +1000 Subject: [PATCH] Add leader-key sequence shortcuts (foundation) 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 #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 --- .../Reducer/SettingsFeature.swift | 49 +++ SupacodeSettingsShared/App/AppShortcuts.swift | 31 ++ .../App/LeaderKeyConflictValidator.swift | 310 ++++++++++++++++++ .../App/LeaderKeySequence.swift | 256 +++++++++++++++ .../Models/GlobalSettings.swift | 16 +- supacode/App/supacodeApp.swift | 5 + .../Views/KeyboardShortcutsSettingsView.swift | 306 ++++++++++++++++- .../Settings/Views/SequenceKeyRecorder.swift | 300 +++++++++++++++++ supacodeTests/AppShortcutsTests.swift | 128 ++++++++ supacodeTests/GlobalSettingsDecodeTests.swift | 112 +++++++ .../LeaderKeyConflictValidatorTests.swift | 136 ++++++++ supacodeTests/LeaderKeySequenceTests.swift | 195 +++++++++++ supacodeTests/SettingsFeatureTests.swift | 161 +++++++++ 13 files changed, 1988 insertions(+), 17 deletions(-) create mode 100644 SupacodeSettingsShared/App/LeaderKeyConflictValidator.swift create mode 100644 SupacodeSettingsShared/App/LeaderKeySequence.swift create mode 100644 supacode/Features/Settings/Views/SequenceKeyRecorder.swift create mode 100644 supacodeTests/GlobalSettingsDecodeTests.swift create mode 100644 supacodeTests/LeaderKeyConflictValidatorTests.swift create mode 100644 supacodeTests/LeaderKeySequenceTests.swift diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index c02ca4d49..90dbfc7c7 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -66,6 +66,11 @@ public struct SettingsFeature { public var defaultWorktreeBaseDirectoryPath: String public var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? public var shortcutOverrides: [AppShortcutID: AppShortcutOverride] + /// Mirror of `GlobalSettings.leaderKey`. `nil` means no leader is configured + /// (the additive, opt-in default); a value carries the leader chord and its + /// sequences. Kept separate from `shortcutOverrides` so single chords are + /// untouched. + public var leaderKey: LeaderKeyConfig? public var globalScripts: [ScriptDefinition] public var richAgentNotificationsEnabled: Bool public var agentPresenceBadgesEnabled: Bool @@ -107,6 +112,7 @@ public struct SettingsFeature { automatedActionPolicy = settings.automatedActionPolicy autoDeleteArchivedWorktreesAfterDays = settings.autoDeleteArchivedWorktreesAfterDays shortcutOverrides = settings.shortcutOverrides + leaderKey = settings.leaderKey globalScripts = settings.globalScripts richAgentNotificationsEnabled = settings.richAgentNotificationsEnabled agentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled @@ -146,6 +152,7 @@ public struct SettingsFeature { ), autoDeleteArchivedWorktreesAfterDays: autoDeleteArchivedWorktreesAfterDays, shortcutOverrides: shortcutOverrides, + leaderKey: leaderKey, globalScripts: globalScripts, richAgentNotificationsEnabled: richAgentNotificationsEnabled, agentPresenceBadgesEnabled: agentPresenceBadgesEnabled, @@ -167,6 +174,10 @@ public struct SettingsFeature { case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?) case toggleShortcutEnabled(id: AppShortcutID, enabled: Bool) case resetAllShortcuts + case updateLeaderChord(AppShortcutOverride) + case updateLeaderSequence(LeaderKeySequence) + case removeLeaderSequence(LeaderKeySequence.ID) + case resetLeaderKey case requestAutoDeleteDaysChange(AutoDeletePeriod?) case resolvedAutoDeleteAffectedCount(AutoDeletePeriod, affectedCount: Int) case cliInstallChecked(installed: Bool) @@ -281,6 +292,7 @@ public struct SettingsFeature { state.automatedActionPolicy = normalizedSettings.automatedActionPolicy state.autoDeleteArchivedWorktreesAfterDays = normalizedSettings.autoDeleteArchivedWorktreesAfterDays state.shortcutOverrides = normalizedSettings.shortcutOverrides + state.leaderKey = normalizedSettings.leaderKey state.globalScripts = normalizedSettings.globalScripts state.richAgentNotificationsEnabled = normalizedSettings.richAgentNotificationsEnabled state.agentPresenceBadgesEnabled = normalizedSettings.agentPresenceBadgesEnabled @@ -458,6 +470,43 @@ public struct SettingsFeature { state.shortcutOverrides = [:] return persist(state) + case .updateLeaderChord(let chord): + // The leader chord owns its sequences, so changing it must preserve them: + // existing sequences re-home onto the new leader rather than being lost + // (REQ-001 AC3). With no config yet, this is the first leader the user picks. + if state.leaderKey != nil { + state.leaderKey?.leaderChord = chord + } else { + state.leaderKey = LeaderKeyConfig(leaderChord: chord) + } + return persist(state) + + case .updateLeaderSequence(let sequence): + // A sequence is anchored to a leader, so there is nothing to attach it to + // until one exists (the Settings leader row is set first). Guard-then-`.none` + // matches the other id-keyed arms (e.g. `removeGlobalScript`). Upsert by id + // so the same action both adds a new sequence and edits an existing one. + guard state.leaderKey != nil else { return .none } + if let index = state.leaderKey?.sequences.firstIndex(where: { $0.id == sequence.id }) { + state.leaderKey?.sequences[index] = sequence + } else { + state.leaderKey?.sequences.append(sequence) + } + return persist(state) + + case .removeLeaderSequence(let id): + // Removing a sequence keeps the leader chord and any other sequences; the + // explicit clear-everything path is `.resetLeaderKey`. + guard state.leaderKey != nil else { return .none } + state.leaderKey?.sequences.removeAll { $0.id == id } + return persist(state) + + case .resetLeaderKey: + // Restore-defaults for the leader slice: no leader, no sequences. Mirrors + // `.resetAllShortcuts` clearing `shortcutOverrides`. + state.leaderKey = nil + return persist(state) + case .requestAutoDeleteDaysChange(let newPeriod): // Apply immediately when safe (disabling or widening the window). // Otherwise, check if the new period would auto-delete existing worktrees. diff --git a/SupacodeSettingsShared/App/AppShortcuts.swift b/SupacodeSettingsShared/App/AppShortcuts.swift index 46097b98a..42b379840 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -416,6 +416,37 @@ public enum AppShortcuts { } } + // MARK: - Leader-key sequence Ghostty bindings. + + // Ghostty key-sequence bindings for the leader-key feature. Each sequence + // lowers to `--keybind=>k1>k2=`: the leader chord's keybind + // token, then every continuation stroke's token, joined by Ghostty's `>` + // sequence separator, bound to the target's built-in action string. + // + // A single `--keybind=escape=end_key_sequence` cancel bind is appended + // when any sequence lowers, giving REQ-004's explicit cancel; unmatched keys + // are flushed to the shell by Ghostty's native pass-through (no app-side + // timeout — HYP-002). Returns `[]` when no leader is configured or nothing + // lowers, so a settings file without a leader leaves the single-chord args + // byte-for-byte unchanged. + // + // A target that does not resolve to a Ghostty built-in (a future + // `.appShortcut` case) is skipped via `LeaderActionTarget.ghosttyAction`, so a + // forward-compat entry never emits a bad bind. + public static func leaderKeyGhosttyKeybindArguments(from config: LeaderKeyConfig?) -> [String] { + guard let config else { return [] } + let leaderToken = config.leaderChord.ghosttyKeybind + + let sequenceArguments = config.sequences.compactMap { sequence -> String? in + guard let action = sequence.target.ghosttyAction, !sequence.keyStrokes.isEmpty else { return nil } + let trigger = ([leaderToken] + sequence.keyStrokes.map(\.ghosttyKeybindToken)).joined(separator: ">") + return "--keybind=\(trigger)=\(action.ghosttyActionString)" + } + + guard !sequenceArguments.isEmpty else { return [] } + return sequenceArguments + ["--keybind=\(leaderToken)>escape=end_key_sequence"] + } + // MARK: - Ghostty CLI arguments. public static var ghosttyCLIKeybindArguments: [String] { diff --git a/SupacodeSettingsShared/App/LeaderKeyConflictValidator.swift b/SupacodeSettingsShared/App/LeaderKeyConflictValidator.swift new file mode 100644 index 000000000..f87f888df --- /dev/null +++ b/SupacodeSettingsShared/App/LeaderKeyConflictValidator.swift @@ -0,0 +1,310 @@ +import Foundation + +// Pure, stateless validator for the leader-key / multi-key sequence feature. +// +// Every finding here is a NON-BLOCKING warning: the Settings UI surfaces them so +// the user can fix an ambiguous or unreachable binding, but nothing prevents a +// config from being saved. The validator does no I/O and reads no `@Shared` +// state — the reserved-key set is injected by the caller (which passes +// `AppShortcutOverride.allReservedDisplayStrings()`), so this function is +// deterministic for its inputs and directly unit-testable without touching the +// host's symbolic-hotkeys defaults. +// +// It reuses the existing shortcut display + reserved model: single-chord display +// strings come from `AppShortcuts.all` resolved through the user's overrides +// (the same source `AppShortcuts.conflictWarnings` uses) and stroke symbols come +// from `AppShortcutOverride.displaySymbols`, so labels match the rest of the +// shortcuts UI and no duplicate reserved/symbol list is introduced. + +// MARK: - One detected conflict. + +// A single conflict, either on the leader chord (config-level) or on one +// sequence. Carries display strings rather than ids so the rendered `message` is +// stable and the UI (T6) can either show the message directly or switch on the +// case for richer presentation (e.g. an icon per kind). +public nonisolated enum LeaderKeyConflict: Equatable, Sendable { + // This sequence's stroke chain is a proper prefix of `other`, so it fires + // before the longer sequence can ever complete (genuinely ambiguous under + // Ghostty's `>` engine). + case prefixOfAnotherSequence(other: String) + // `other`'s stroke chain is a proper prefix of this sequence's, so the shorter + // one fires first and this sequence can never be reached. + case prefixedByAnotherSequence(other: String) + // The exact same stroke chain is bound by another sequence. + case duplicateSequence + // The sequence's stroke display path equals a live single-chord shortcut, so + // the same chord means two different things depending on context. + case collidesWithShortcut(chord: String, shortcut: String) + // The sequence begins with Escape, which is reserved as the leader-cancel key + // (the `escape=end_key_sequence` bind), so it can never fire. + case usesReservedCancelKey + // The leader chord is a system-reserved chord. + case leaderReserved(chord: String) + // The leader chord equals a live single-chord shortcut. + case leaderCollidesWithShortcut(chord: String, shortcut: String) + + // User-facing warning text. Concise and phrased like the existing + // single-chord conflict warnings. + public var message: String { + switch self { + case .prefixOfAnotherSequence(let other): + "This sequence is a prefix of \(other), so it fires before that sequence can complete." + case .prefixedByAnotherSequence(let other): + "\(other) is a prefix of this sequence, so this sequence can never fire." + case .duplicateSequence: + "This sequence is already bound to another action." + case .collidesWithShortcut(let chord, let shortcut): + "\(chord) is also bound to \(shortcut)." + case .usesReservedCancelKey: + "Escape after the leader is reserved to cancel the sequence." + case .leaderReserved(let chord): + "\(chord) is reserved by the system." + case .leaderCollidesWithShortcut(let chord, let shortcut): + "\(chord) is also bound to \(shortcut)." + } + } +} + +// MARK: - Validation report. + +// The full result of one validation pass. Leader-chord conflicts are reported +// once at the top level (the leader is shared by every sequence); per-sequence +// conflicts are keyed by sequence id so the UI can render a warning under each +// row. A sequence with no conflicts has no entry, so `conflicts(for:)` returns +// an empty array rather than requiring the caller to handle a missing key. +public nonisolated struct LeaderKeyConflictReport: Equatable, Sendable { + public var leaderConflicts: [LeaderKeyConflict] + public var sequenceConflicts: [LeaderKeySequence.ID: [LeaderKeyConflict]] + + public init( + leaderConflicts: [LeaderKeyConflict] = [], + sequenceConflicts: [LeaderKeySequence.ID: [LeaderKeyConflict]] = [:], + ) { + self.leaderConflicts = leaderConflicts + self.sequenceConflicts = sequenceConflicts + } + + public var isEmpty: Bool { + leaderConflicts.isEmpty && sequenceConflicts.isEmpty + } + + public func conflicts(for sequenceID: LeaderKeySequence.ID) -> [LeaderKeyConflict] { + sequenceConflicts[sequenceID] ?? [] + } +} + +// MARK: - Validator. + +public nonisolated enum LeaderKeyConflictValidator { + // Validates a leader configuration and returns every non-blocking conflict. + // + // - Parameters: + // - config: the leader configuration to check; `nil` (no leader) yields an + // empty report. + // - shortcutOverrides: the user's single-chord overrides, used (with the + // built-in `AppShortcuts.all` defaults) to compute the live single-chord + // display strings a leader chord or sequence might collide with. + // - reservedDisplayStrings: system/AppKit reserved chord display strings. + // Injected (rather than read here) to keep the validator pure; live + // callers pass `AppShortcutOverride.allReservedDisplayStrings()`. + public static func validate( + config: LeaderKeyConfig?, + shortcutOverrides: [AppShortcutID: AppShortcutOverride], + reservedDisplayStrings: Set, + ) -> LeaderKeyConflictReport { + guard let config else { return LeaderKeyConflictReport() } + + let singleChordsByDisplay = singleChordDisplayMap(overrides: shortcutOverrides) + + let leaderConflicts = leaderChordConflicts( + leaderChord: config.leaderChord, + singleChordsByDisplay: singleChordsByDisplay, + reservedDisplayStrings: reservedDisplayStrings, + ) + + var sequenceConflicts: [LeaderKeySequence.ID: [LeaderKeyConflict]] = [:] + for (sequenceID, conflicts) in sequenceConflictMap( + sequences: config.sequences, + leaderChord: config.leaderChord, + singleChordsByDisplay: singleChordsByDisplay, + ) where !conflicts.isEmpty { + // Sort by message so the rendered order is stable regardless of trie + // traversal order, keeping the UI and tests deterministic. + sequenceConflicts[sequenceID] = conflicts.sorted { $0.message < $1.message } + } + + return LeaderKeyConflictReport( + leaderConflicts: leaderConflicts, + sequenceConflicts: sequenceConflicts, + ) + } + + // MARK: - Leader chord. + + private static func leaderChordConflicts( + leaderChord: AppShortcutOverride, + singleChordsByDisplay: [String: String], + reservedDisplayStrings: Set, + ) -> [LeaderKeyConflict] { + let display = leaderChord.displayString + var conflicts: [LeaderKeyConflict] = [] + if reservedDisplayStrings.contains(display) { + conflicts.append(.leaderReserved(chord: display)) + } + if let shortcut = singleChordsByDisplay[display] { + conflicts.append(.leaderCollidesWithShortcut(chord: display, shortcut: shortcut)) + } + return conflicts + } + + // MARK: - Sequences. + + private static func sequenceConflictMap( + sequences: [LeaderKeySequence], + leaderChord: AppShortcutOverride, + singleChordsByDisplay: [String: String], + ) -> [LeaderKeySequence.ID: [LeaderKeyConflict]] { + var conflicts: [LeaderKeySequence.ID: [LeaderKeyConflict]] = [:] + func add(_ conflict: LeaderKeyConflict, to sequenceID: LeaderKeySequence.ID) { + conflicts[sequenceID, default: []].append(conflict) + } + + let byID = Dictionary(sequences.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + func displayPath(of sequenceID: LeaderKeySequence.ID) -> String { + guard let sequence = byID[sequenceID] else { return "" } + return sequencePathDisplay(sequence, leaderChord: leaderChord) + } + + // Prefix + duplicate detection via a trie keyed on each stroke's Ghostty + // token (exactly what the `>` engine matches on, so this mirrors real + // runtime ambiguity rather than mere visual similarity). + let trie = SequenceTrie() + for sequence in sequences where !sequence.keyStrokes.isEmpty { + trie.insert(tokens: sequence.keyStrokes.map(\.ghosttyKeybindToken), sequenceID: sequence.id) + } + for relation in trie.prefixRelations() { + add(.prefixOfAnotherSequence(other: displayPath(of: relation.longer)), to: relation.shorter) + add(.prefixedByAnotherSequence(other: displayPath(of: relation.shorter)), to: relation.longer) + } + for duplicateID in trie.duplicateSequenceIDs() { + add(.duplicateSequence, to: duplicateID) + } + + // Per-sequence collisions independent of other sequences. + for sequence in sequences where !sequence.keyStrokes.isEmpty { + let strokePath = sequence.keyStrokes.flatMap(\.displaySymbols).joined() + if let shortcut = singleChordsByDisplay[strokePath] { + add(.collidesWithShortcut(chord: strokePath, shortcut: shortcut), to: sequence.id) + } + if sequence.keyStrokes.first?.ghosttyKeybindToken == "escape" { + add(.usesReservedCancelKey, to: sequence.id) + } + } + + return conflicts + } + + // MARK: - Display helpers. + + // Live single-chord shortcut display strings mapped to their action name, from + // the built-in shortcuts resolved through the user's overrides. A disabled + // shortcut contributes nothing (its chord is free), matching how the rest of + // the shortcut system computes effective bindings. + private static func singleChordDisplayMap( + overrides: [AppShortcutID: AppShortcutOverride] + ) -> [String: String] { + var map: [String: String] = [:] + for shortcut in AppShortcuts.all { + guard let effective = shortcut.effective(from: overrides) else { continue } + map[effective.display] = effective.displayName + } + return map + } + + // The full recognizable chord chain for a sequence, e.g. "⌘K W C". + private static func sequencePathDisplay( + _ sequence: LeaderKeySequence, + leaderChord: AppShortcutOverride, + ) -> String { + ([leaderChord.displayString] + sequence.keyStrokes.map(\.displayString)).joined(separator: " ") + } +} + +// MARK: - Trie. + +// Minimal prefix tree over sequence stroke-token chains. It is the natural +// structure for "is X a proper prefix of Y" across a set and yields both prefix +// relations and exact duplicates in one build. Used only within a synchronous +// validation pass, so it never crosses a concurrency boundary. +private final class SequenceTrie { + private final class Node { + var children: [String: Node] = [:] + var terminals: [LeaderKeySequence.ID] = [] + } + + // A sequence (`shorter`) whose token chain is a proper prefix of another + // (`longer`). + struct PrefixRelation { + let shorter: LeaderKeySequence.ID + let longer: LeaderKeySequence.ID + } + + private let root = Node() + + func insert(tokens: [String], sequenceID: LeaderKeySequence.ID) { + var node = root + for token in tokens { + if let next = node.children[token] { + node = next + } else { + let next = Node() + node.children[token] = next + node = next + } + } + node.terminals.append(sequenceID) + } + + // Every (shorter, longer) pair where the shorter sequence terminates strictly + // above the longer one along the same chain. + func prefixRelations() -> [PrefixRelation] { + var relations: [PrefixRelation] = [] + visit(root) { node in + guard !node.terminals.isEmpty else { return } + let descendants = descendantTerminals(of: node) + for shorter in node.terminals { + for longer in descendants { + relations.append(PrefixRelation(shorter: shorter, longer: longer)) + } + } + } + return relations + } + + // Sequence ids that share an identical token chain with at least one other + // sequence (i.e. terminate at the same node). + func duplicateSequenceIDs() -> [LeaderKeySequence.ID] { + var duplicates: [LeaderKeySequence.ID] = [] + visit(root) { node in + if node.terminals.count > 1 { + duplicates.append(contentsOf: node.terminals) + } + } + return duplicates + } + + private func descendantTerminals(of node: Node) -> [LeaderKeySequence.ID] { + var result: [LeaderKeySequence.ID] = [] + for child in node.children.values { + visit(child) { result.append(contentsOf: $0.terminals) } + } + return result + } + + private func visit(_ node: Node, _ body: (Node) -> Void) { + body(node) + for child in node.children.values { + visit(child, body) + } + } +} diff --git a/SupacodeSettingsShared/App/LeaderKeySequence.swift b/SupacodeSettingsShared/App/LeaderKeySequence.swift new file mode 100644 index 000000000..43560c098 --- /dev/null +++ b/SupacodeSettingsShared/App/LeaderKeySequence.swift @@ -0,0 +1,256 @@ +import Foundation + +// Model for the leader-key / multi-key sequence feature. +// +// A `LeaderKeyConfig` is one optional slice of `GlobalSettings`: a leader chord +// plus an ordered list of `LeaderKeySequence`s. Each sequence is a non-empty list +// of continuation key strokes that, pressed after the leader, fire one action. +// +// v1 scope is "Foundation only": a sequence may target ONLY a Ghostty built-in +// action that the existing surface bridge already routes to host behavior (the +// closed `GhosttyLeaderAction` set). The target is modeled as the extensible +// `LeaderActionTarget` sum type so a future host-routable app action can slot in +// additively without breaking already-persisted sequences. + +// MARK: - Single continuation key stroke. + +// One key in a sequence: a key code plus the optional modifier flags held with it. +// Continuation keys are typically unmodified, so `modifiers` is optional. +public nonisolated struct SequenceKeyStroke: Codable, Equatable, Sendable { + public var keyCode: UInt16 + public var modifiers: AppShortcutOverride.ModifierFlags? + + public init(keyCode: UInt16, modifiers: AppShortcutOverride.ModifierFlags? = nil) { + self.keyCode = keyCode + self.modifiers = modifiers + } + + // Reuses the single-chord symbol mapping so sequence labels match the rest of + // the shortcuts UI; no duplicate symbol logic is introduced here. + public var displaySymbols: [String] { + AppShortcutOverride.displaySymbols(for: keyCode, modifiers: modifiers ?? []) + } + + public var displayString: String { + displaySymbols.joined() + } + + // The Ghostty keybind token for this single stroke (e.g. `w`, `shift+c`). + // Sequences join these with `>` when lowered to Ghostty's key-sequence engine. + // Reuses `AppShortcutOverride.ghosttyKeybind` so the modifier mapping stays in + // one place. + public var ghosttyKeybindToken: String { + AppShortcutOverride(keyCode: keyCode, modifiers: modifiers ?? []).ghosttyKeybind + } +} + +// MARK: - Split directions. + +// Direction tokens for `new_split` / `resize_split`. Raw values intentionally +// match the Ghostty keybind grammar so `ghosttyToken` is the raw value. +public nonisolated enum SplitDirection: String, Codable, Equatable, Sendable, CaseIterable { + case up + case down + case left + case right + + public var ghosttyToken: String { rawValue } + + public var displayName: String { + switch self { + case .up: "Up" + case .down: "Down" + case .left: "Left" + case .right: "Right" + } + } +} + +// Direction tokens for `goto_split`, which also supports relative focus movement. +// Raw values match the Ghostty keybind grammar. +public nonisolated enum SplitFocusDirection: String, Codable, Equatable, Sendable, CaseIterable { + case previous + case next + case up + case down + case left + case right + + public var ghosttyToken: String { rawValue } + + public var displayName: String { + switch self { + case .previous: "Previous" + case .next: "Next" + case .up: "Up" + case .down: "Down" + case .left: "Left" + case .right: "Right" + } + } +} + +// MARK: - Ghostty built-in actions. + +// The closed set of Ghostty built-in actions a v1 leader sequence can fire. +// Every case here is already routed to host behavior by `GhosttySurfaceBridge` +// (new_tab / close_tab / goto_tab / move_tab / toggle_command_palette / splits). +// Menu-only app actions are deliberately NOT representable here, so a sequence +// can never be bound to an action that would silently no-op. +public nonisolated enum GhosttyLeaderAction: Codable, Equatable, Sendable { + case newTab + case closeTab + case gotoTab(index: Int) + case moveTab(offset: Int) + case toggleCommandPalette + case newSplit(direction: SplitDirection) + case gotoSplit(direction: SplitFocusDirection) + case resizeSplit(direction: SplitDirection, amount: UInt16) + case equalizeSplits + case toggleSplitZoom + + // The Ghostty keybind action string this action lowers to. Parameterized + // strings are pinned against Ghostty's keybind grammar. + public var ghosttyActionString: String { + switch self { + case .newTab: "new_tab" + case .closeTab: "close_tab" + case .gotoTab(let index): "goto_tab:\(index)" + case .moveTab(let offset): "move_tab:\(offset)" + case .toggleCommandPalette: "toggle_command_palette" + case .newSplit(let direction): "new_split:\(direction.ghosttyToken)" + case .gotoSplit(let direction): "goto_split:\(direction.ghosttyToken)" + case .resizeSplit(let direction, let amount): "resize_split:\(direction.ghosttyToken),\(amount)" + case .equalizeSplits: "equalize_splits" + case .toggleSplitZoom: "toggle_split_zoom" + } + } + + // Human-readable label for the Settings target picker and tooltips. + public var displayName: String { + switch self { + case .newTab: "New Tab" + case .closeTab: "Close Tab" + case .gotoTab(let index): "Jump to Worktree \(index)" + case .moveTab(let offset): "Move Tab by \(offset)" + case .toggleCommandPalette: "Toggle Command Palette" + case .newSplit(let direction): "New Split \(direction.displayName)" + case .gotoSplit(let direction): "Focus \(direction.displayName) Split" + case .resizeSplit(let direction, let amount): "Resize Split \(direction.displayName) by \(amount)" + case .equalizeSplits: "Equalize Splits" + case .toggleSplitZoom: "Toggle Split Zoom" + } + } +} + +// MARK: - Action target (extensible sum type). + +// What a sequence fires. v1 carries only `.ghostty(...)`; the type is an +// extensible sum so a future host-routable app action can be added as a new +// case (e.g. `.appShortcut(AppShortcutID)`) without changing the persisted wire +// format. Decode of an unknown target kind throws, so a forward-compat entry is +// lossy-dropped by the enclosing `sequences` array rather than failing launch. +public nonisolated enum LeaderActionTarget: Codable, Equatable, Sendable { + case ghostty(GhosttyLeaderAction) + + // The Ghostty built-in this target resolves to, or `nil` for any future + // non-Ghostty target. Lowering and the picker key off this. + public var ghosttyAction: GhosttyLeaderAction? { + switch self { + case .ghostty(let action): action + } + } + + private enum CodingKeys: String, CodingKey { + case kind + case ghostty + } + + // Discriminator persisted as `kind`. An unknown raw value (a future case + // written by a newer build) fails to decode and is dropped upstream. + private enum Kind: String, Codable { + case ghostty + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .kind) + switch kind { + case .ghostty: + self = .ghostty(try container.decode(GhosttyLeaderAction.self, forKey: .ghostty)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .ghostty(let action): + try container.encode(Kind.ghostty, forKey: .kind) + try container.encode(action, forKey: .ghostty) + } + } +} + +// MARK: - One leader sequence. + +// An ordered, non-empty list of continuation strokes bound to one target. +// A decoded sequence with an empty stroke list is treated as malformed and +// dropped by the enclosing `sequences` array. +public nonisolated struct LeaderKeySequence: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public var keyStrokes: [SequenceKeyStroke] + public var target: LeaderActionTarget + + public init(id: UUID = UUID(), keyStrokes: [SequenceKeyStroke], target: LeaderActionTarget) { + self.id = id + self.keyStrokes = keyStrokes + self.target = target + } + + private enum CodingKeys: String, CodingKey { + case id + case keyStrokes + case target + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + let keyStrokes = try container.decode([SequenceKeyStroke].self, forKey: .keyStrokes) + guard !keyStrokes.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .keyStrokes, + in: container, + debugDescription: "A leader sequence must have at least one key stroke.", + ) + } + self.keyStrokes = keyStrokes + self.target = try container.decode(LeaderActionTarget.self, forKey: .target) + } +} + +// MARK: - Full leader configuration. + +// The persisted leader-key slice: the leader chord plus its sequences. Optional +// on `GlobalSettings`; absent means no leader is configured. Malformed sequence +// entries are dropped on decode while valid ones survive. +public nonisolated struct LeaderKeyConfig: Codable, Equatable, Sendable { + public var leaderChord: AppShortcutOverride + public var sequences: [LeaderKeySequence] + + public init(leaderChord: AppShortcutOverride, sequences: [LeaderKeySequence] = []) { + self.leaderChord = leaderChord + self.sequences = sequences + } + + private enum CodingKeys: String, CodingKey { + case leaderChord + case sequences + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.leaderChord = try container.decode(AppShortcutOverride.self, forKey: .leaderChord) + self.sequences = container.decodeLossyArrayIfPresent(forKey: .sequences) ?? [] + } +} diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index 42d4258cc..c4a4a656d 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -52,6 +52,10 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { public var automatedActionPolicy: AutomatedActionPolicy public var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? public var shortcutOverrides: [AppShortcutID: AppShortcutOverride] + /// Optional leader chord plus multi-key sequences, additive and fully + /// separate from `shortcutOverrides` (single chords are untouched). `nil` + /// means no leader is configured; old files without this key decode to `nil`. + public var leaderKey: LeaderKeyConfig? /// Scripts shared across every repository. Always `.custom` kind. public var globalScripts: [ScriptDefinition] /// User-configured remote repositories reachable over SSH. Materialized at @@ -96,13 +100,14 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { defaultWorktreeBaseDirectoryPath: nil, autoDeleteArchivedWorktreesAfterDays: nil, shortcutOverrides: [:], + leaderKey: nil, globalScripts: [], remoteRepositories: [], richAgentNotificationsEnabled: true, agentPresenceBadgesEnabled: true, autoUpdateAgentIntegrationsEnabled: true, confirmQuitMode: .auto, - terminateSessionsOnQuit: false + terminateSessionsOnQuit: false, ) public init( @@ -131,13 +136,14 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { defaultWorktreeBaseDirectoryPath: String? = nil, autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? = nil, shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:], + leaderKey: LeaderKeyConfig? = nil, globalScripts: [ScriptDefinition] = [], remoteRepositories: [RemoteRepositoryConfig] = [], richAgentNotificationsEnabled: Bool = true, agentPresenceBadgesEnabled: Bool = true, autoUpdateAgentIntegrationsEnabled: Bool = true, confirmQuitMode: ConfirmQuitMode = .auto, - terminateSessionsOnQuit: Bool = false + terminateSessionsOnQuit: Bool = false, ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -164,6 +170,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath self.autoDeleteArchivedWorktreesAfterDays = autoDeleteArchivedWorktreesAfterDays self.shortcutOverrides = shortcutOverrides + self.leaderKey = leaderKey self.globalScripts = globalScripts self.remoteRepositories = remoteRepositories self.richAgentNotificationsEnabled = richAgentNotificationsEnabled @@ -278,6 +285,11 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { shortcutOverrides = try container.decodeIfPresent([AppShortcutID: AppShortcutOverride].self, forKey: .shortcutOverrides) ?? Self.default.shortcutOverrides + // Additive: a missing key decodes to `nil` (no leader), so existing files + // are unaffected. `LeaderKeyConfig.init(from:)` already drops a malformed + // `sequences` entry (bad stroke list or unknown target) via + // `decodeLossyArrayIfPresent` while keeping the valid ones. + leaderKey = try container.decodeIfPresent(LeaderKeyConfig.self, forKey: .leaderKey) ?? nil // Force `.custom` so a forged `kind` can't hijack the primary toolbar slot. // No legacy migration here, so missing-key and corrupt-array both collapse // to `[]` (unlike `RepositorySettings.scripts` which distinguishes them). diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index f815b1338..d8cb8c367 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -24,6 +24,11 @@ private enum GhosttyCLI { for keybindArgument in AppShortcuts.ghosttyCLIKeybindArguments(from: overrides) { args.append(strdup(keybindArgument)) } + // Leader-key sequence binds are appended after the single-chord args so they + // stay first and unchanged; empty unless a leader is configured. + for keybindArgument in AppShortcuts.leaderKeyGhosttyKeybindArguments(from: settingsFile.global.leaderKey) { + args.append(strdup(keybindArgument)) + } args.append(nil) return args }() diff --git a/supacode/Features/Settings/Views/KeyboardShortcutsSettingsView.swift b/supacode/Features/Settings/Views/KeyboardShortcutsSettingsView.swift index d2fa25c19..cf80f511d 100644 --- a/supacode/Features/Settings/Views/KeyboardShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/KeyboardShortcutsSettingsView.swift @@ -1,3 +1,4 @@ +import Carbon.HIToolbox import ComposableArchitecture import SupacodeSettingsFeature import SupacodeSettingsShared @@ -56,6 +57,12 @@ struct KeyboardShortcutsSettingsView: View { !store.shortcutOverrides.isEmpty } + // Restore-defaults is also the path that clears a configured leader, so it + // stays enabled whenever either single chords or a leader are customized. + private var canRestoreDefaults: Bool { + hasAnyOverrides || store.leaderKey != nil + } + private var warningsByID: [AppShortcutID: String] { var warnings = AppShortcuts.conflictWarnings(from: store.shortcutOverrides) let terminalDisplays = ghosttyShortcuts.reservedDisplayStrings @@ -72,6 +79,43 @@ struct KeyboardShortcutsSettingsView: View { var body: some View { let warnings = warningsByID let terminalDisplays = ghosttyShortcuts.reservedDisplayStrings + VStack(spacing: 0) { + LeaderKeyConfigurationView(store: store, terminalReservedDisplays: terminalDisplays) + .padding() + Divider() + shortcutsTable(warnings: warnings, terminalDisplays: terminalDisplays) + } + .navigationTitle("Shortcuts") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showRestoreConfirmation = true + } label: { + Image(systemName: "arrow.counterclockwise") + .accessibilityLabel("Restore Defaults") + } + .help("Restore all shortcuts, including the leader key and its sequences, to their defaults.") + .disabled(!canRestoreDefaults) + .confirmationDialog( + "Restore all keyboard shortcuts to their defaults?", + isPresented: $showRestoreConfirmation, + titleVisibility: .visible, + ) { + Button("Restore Defaults", role: .destructive) { + store.send(.resetAllShortcuts) + store.send(.resetLeaderKey) + } + } message: { + Text("This clears every custom shortcut and removes the leader key and all of its sequences.") + } + } + } + } + + private func shortcutsTable( + warnings: [AppShortcutID: String], + terminalDisplays: Set, + ) -> some View { Table(of: ShortcutTableItem.self) { TableColumn("Name") { item in NameCell(item: item, overrides: store.shortcutOverrides) @@ -110,28 +154,260 @@ struct KeyboardShortcutsSettingsView: View { .alternatingRowBackgrounds() .padding(.leading, -6) .searchable(text: $searchText, placement: .toolbar, prompt: "Search...") - .navigationTitle("Shortcuts") - .toolbar { - ToolbarItem(placement: .primaryAction) { + } +} + +// MARK: - Leader key configuration. + +// The leader-key configuration surface: a single chord row (set / clear the +// leader) plus a list of multi-key sequences (leader + ordered strokes -> a +// host-routable Ghostty built-in). Conflict warnings from the pure +// `LeaderKeyConflictValidator` are surfaced inline and are non-blocking. Sits +// above the per-action single-chord table so the two binding styles stay visually +// distinct. Keybinds apply at launch, mirroring single-chord overrides, so the +// row notes "applies after relaunch". +// +// which-key overlay seam: a discoverability popup that shows the available next +// keys mid-sequence is intentionally deferred (design D7). A future overlay can +// observe `GhosttySurfaceState.keySequenceActive` / `keyTableName` (already wired +// by `GhosttySurfaceBridge`) without re-plumbing this view. +private struct LeaderKeyConfigurationView: View { + let store: StoreOf + let terminalReservedDisplays: Set + + @State private var isRecordingLeader = false + @State private var editTarget: SequenceEditTarget? + + // Identifies the editor sheet. A new sequence has no underlying `existing`. + private struct SequenceEditTarget: Identifiable { + let id: UUID + let existing: LeaderKeySequence? + } + + // Default leader suggestion (D1): ⌘K is free in-app and is a modifier chord + // (REQ-001). Pre-filling the recorder makes the leader discoverable without + // seeding it, so nothing is intercepted until the user sets a leader. + private static let suggestedLeader = AppShortcutOverride( + keyCode: UInt16(kVK_ANSI_K), + modifiers: .command, + ) + + // Reserved chords for both the leader-conflict check and the pre-commit + // recorder check: system/AppKit reserved plus the terminal's own bindings, + // so a leader that the terminal would swallow is flagged. + private var reservedDisplayStrings: Set { + AppShortcutOverride.allReservedDisplayStrings().union(terminalReservedDisplays) + } + + private var report: LeaderKeyConflictReport { + LeaderKeyConflictValidator.validate( + config: store.leaderKey, + shortcutOverrides: store.shortcutOverrides, + reservedDisplayStrings: reservedDisplayStrings, + ) + } + + var body: some View { + let report = report + VStack(alignment: .leading, spacing: 12) { + header + leaderRow(report: report) + if store.leaderKey != nil { + Divider() + sequencesSection(report: report) + } + } + .sheet(item: $editTarget) { target in + LeaderSequenceEditorSheet( + existing: target.existing, + onSave: { sequence in + store.send(.updateLeaderSequence(sequence)) + editTarget = nil + }, + onCancel: { editTarget = nil }, + ) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Leader Key") + .font(.headline) + Text("Press the leader chord, then a sequence of keys, to run an action. Applies after relaunch.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func leaderRow(report: LeaderKeyConflictReport) -> some View { + HStack(spacing: 8) { + if let leaderKey = store.leaderKey { + HStack(spacing: 3) { + ForEach(Array(leaderKey.leaderChord.displaySymbols.enumerated()), id: \.offset) { _, symbol in + Keycap(symbol: symbol) + } + } + Button("Change…") { isRecordingLeader = true } + .help("Record a new leader chord. Existing sequences are kept.") + Button(role: .destructive) { + store.send(.resetLeaderKey) + } label: { + Text("Clear") + } + .help("Remove the leader key and all of its sequences.") + } else { + Text("No leader key set") + .foregroundStyle(.secondary) + Button("Use \(Self.suggestedLeader.displayString)") { + store.send(.updateLeaderChord(Self.suggestedLeader)) + } + .help("Use the suggested \(Self.suggestedLeader.displayString) leader chord.") + Button("Set Leader…") { isRecordingLeader = true } + .help("Record a custom leader chord. A modifier (such as ⌘) is required.") + } + Spacer() + } + .popover(isPresented: $isRecordingLeader) { + HotkeyRecorderPopover( + onRecorded: { override in store.send(.updateLeaderChord(override)) }, + onCancelled: { isRecordingLeader = false }, + conflictChecker: leaderConflictChecker, + ) + } + if !report.leaderConflicts.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(report.leaderConflicts.enumerated()), id: \.offset) { _, conflict in + LeaderConflictLabel(message: conflict.message) + } + } + } + } + + @ViewBuilder + private func sequencesSection(report: LeaderKeyConflictReport) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Sequences") + .font(.subheadline.weight(.medium)) + Spacer() Button { - showRestoreConfirmation = true + editTarget = SequenceEditTarget(id: UUID(), existing: nil) } label: { - Image(systemName: "arrow.counterclockwise") - .accessibilityLabel("Restore Defaults") + Label("Add Sequence", systemImage: "plus") } - .help("Restore all shortcuts to their default values.") - .disabled(!hasAnyOverrides) - .confirmationDialog( - "Restore all keyboard shortcuts to their defaults?", - isPresented: $showRestoreConfirmation, - titleVisibility: .visible - ) { - Button("Restore Defaults", role: .destructive) { - store.send(.resetAllShortcuts) + .help("Add a new leader sequence.") + } + if let sequences = store.leaderKey?.sequences, !sequences.isEmpty { + ForEach(sequences) { sequence in + LeaderSequenceRow( + leaderChord: store.leaderKey?.leaderChord, + sequence: sequence, + conflicts: report.conflicts(for: sequence.id), + onEdit: { editTarget = SequenceEditTarget(id: sequence.id, existing: sequence) }, + onRemove: { store.send(.removeLeaderSequence(sequence.id)) }, + ) + } + } else { + Text("No sequences yet. Add one to bind a leader sequence to an action.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // Pre-commit conflict surface for the leader recorder popover, mirroring the + // single-chord recorder: returns the colliding owner's name, or `nil`. + private func leaderConflictChecker(_ proposed: AppShortcutOverride) -> String? { + let proposedDisplay = proposed.displayString + guard !AppShortcutOverride.allReservedDisplayStrings().contains(proposedDisplay) else { + return "System" + } + guard !terminalReservedDisplays.contains(proposedDisplay) else { return "Terminal" } + for shortcut in AppShortcuts.all { + guard let effective = shortcut.effective(from: store.shortcutOverrides) else { continue } + guard effective.display == proposedDisplay else { continue } + return shortcut.displayName + } + return nil + } +} + +// MARK: - Leader sequence row. + +private struct LeaderSequenceRow: View { + let leaderChord: AppShortcutOverride? + let sequence: LeaderKeySequence + let conflicts: [LeaderKeyConflict] + let onEdit: () -> Void + let onRemove: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + HStack(spacing: 3) { + if let leaderChord { + ForEach(Array(leaderChord.displaySymbols.enumerated()), id: \.offset) { _, symbol in + Keycap(symbol: symbol) + } + } + ForEach(Array(sequence.keyStrokes.enumerated()), id: \.offset) { _, stroke in + ForEach(Array(stroke.displaySymbols.enumerated()), id: \.offset) { _, symbol in + Keycap(symbol: symbol) + } } } + Image(systemName: "arrow.right") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(sequence.target.ghosttyAction?.displayName ?? "Unknown action") + .foregroundStyle(.primary) + Spacer() + Button { + onEdit() + } label: { + Image(systemName: "pencil") + .accessibilityLabel("Edit Sequence") + } + .buttonStyle(.borderless) + .help("Edit this leader sequence.") + Button { + onRemove() + } label: { + Image(systemName: "trash") + .accessibilityLabel("Remove Sequence") + } + .buttonStyle(.borderless) + .help("Remove this leader sequence.") + } + ForEach(Array(conflicts.enumerated()), id: \.offset) { _, conflict in + LeaderConflictLabel(message: conflict.message) } } + .padding(.vertical, 2) + } +} + +// MARK: - Conflict label. + +// Inline, non-blocking conflict warning, styled like the single-chord table's +// warning affordance (a yellow triangle) for consistency. +private struct LeaderConflictLabel: View { + let message: String + + var body: some View { + Label { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Warning: \(message)") } } diff --git a/supacode/Features/Settings/Views/SequenceKeyRecorder.swift b/supacode/Features/Settings/Views/SequenceKeyRecorder.swift new file mode 100644 index 000000000..4dbe45c29 --- /dev/null +++ b/supacode/Features/Settings/Views/SequenceKeyRecorder.swift @@ -0,0 +1,300 @@ +import AppKit +import Carbon.HIToolbox +import SupacodeSettingsShared +import SwiftUI + +// Editor + key-capture surface for a single leader-key sequence (leader + ordered +// continuation strokes -> one Ghostty built-in action). +// +// The leader chord itself is captured by the shared `HotkeyRecorderPopover` +// (modifier required, REQ-001). Continuation strokes are different: they are +// typically unmodified mnemonics (`w`, `c`), which that recorder forbids, so this +// file adds a focused recorder that accepts no-modifier keys, uses Escape to +// finish capture, and Backspace to delete the last stroke (design D5). +// +// The target picker offers ONLY the closed `GhosttyLeaderAction` set (the +// host-routable built-ins). Menu-only app actions are deliberately not offered: +// lowered to Ghostty they would silently no-op, so excluding them here is the +// foundation-scope guardrail (HYP-001 / design D2). + +// MARK: - Target catalog. + +// The concrete, user-selectable `GhosttyLeaderAction` values surfaced by the +// target picker. Which concrete parameters to offer (worktree count, resize +// amount, tab-move offsets) is a UI presentation choice, so it lives here rather +// than on the model. Every entry is a host-routable built-in; labels come from +// `GhosttyLeaderAction.displayName` so they match the rest of the shortcuts UI. +enum GhosttyLeaderActionCatalog { + // Display grouping for the picker. Raw value is the section header. + enum Section: String, CaseIterable { + case tabs = "Tabs" + case worktrees = "Worktrees" + case splits = "Splits" + case commandPalette = "Command Palette" + } + + static func actions(in section: Section) -> [GhosttyLeaderAction] { + switch section { + case .tabs: + [.newTab, .closeTab, .moveTab(offset: -1), .moveTab(offset: 1)] + case .worktrees: + (1...9).map { GhosttyLeaderAction.gotoTab(index: $0) } + case .splits: + SplitDirection.allCases.map { GhosttyLeaderAction.newSplit(direction: $0) } + + SplitFocusDirection.allCases.map { GhosttyLeaderAction.gotoSplit(direction: $0) } + + SplitDirection.allCases.map { GhosttyLeaderAction.resizeSplit(direction: $0, amount: defaultResizeAmount) } + + [.equalizeSplits, .toggleSplitZoom] + case .commandPalette: + [.toggleCommandPalette] + } + } + + // Every offered action, flattened. Used to resolve a picked action string back + // to its `GhosttyLeaderAction` on save. + static let all: [GhosttyLeaderAction] = Section.allCases.flatMap { actions(in: $0) } + + // The default split-resize step. A fixed amount keeps the picker a flat list + // instead of exploding into a per-amount matrix. + private static let defaultResizeAmount: UInt16 = 10 + + // Resolve a picked Ghostty action string back to the catalog entry. The action + // string is unique per concrete action, so it is a stable picker tag. + static func action(forActionString actionString: String) -> GhosttyLeaderAction? { + all.first { $0.ghosttyActionString == actionString } + } +} + +// MARK: - Sequence editor sheet. + +// Adds a new sequence or edits an existing one. Owns a local draft (strokes + +// target) so nothing is committed until the user saves; on save it builds a +// `LeaderKeySequence` (preserving the id when editing, so the reducer's upsert +// edits in place) and hands it back to the caller. +struct LeaderSequenceEditorSheet: View { + // The sequence being edited, or `nil` to create a new one. + let existing: LeaderKeySequence? + let onSave: (LeaderKeySequence) -> Void + let onCancel: () -> Void + + @State private var keyStrokes: [SequenceKeyStroke] + @State private var selectedActionString: String + @State private var isCapturing: Bool + + init( + existing: LeaderKeySequence?, + onSave: @escaping (LeaderKeySequence) -> Void, + onCancel: @escaping () -> Void, + ) { + self.existing = existing + self.onSave = onSave + self.onCancel = onCancel + _keyStrokes = State(initialValue: existing?.keyStrokes ?? []) + let initialAction = + existing?.target.ghosttyAction ?? GhosttyLeaderActionCatalog.all.first ?? .newTab + _selectedActionString = State(initialValue: initialAction.ghosttyActionString) + // Start capturing immediately for a new sequence; when editing, the existing + // strokes are shown and capture appends to them (Backspace / Clear let the + // user correct). + _isCapturing = State(initialValue: existing == nil) + } + + private var canSave: Bool { !keyStrokes.isEmpty } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(existing == nil ? "Add Sequence" : "Edit Sequence") + .font(.headline) + + VStack(alignment: .leading, spacing: 8) { + Text("Keys") + .font(.subheadline.weight(.medium)) + SequenceStrokeField( + keyStrokes: keyStrokes, + isCapturing: isCapturing, + onRecordStroke: { stroke in keyStrokes.append(stroke) }, + onDeleteLast: { _ = keyStrokes.popLast() }, + onFinish: { isCapturing = false }, + ) + HStack(spacing: 8) { + if isCapturing { + Text("Press keys in order · ⎋ to finish · ⌫ to delete last") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button("Record Keys") { isCapturing = true } + .help("Capture the key strokes pressed after the leader.") + } + Spacer() + Button("Clear") { + keyStrokes.removeAll() + isCapturing = true + } + .help("Remove all captured key strokes and start over.") + .disabled(keyStrokes.isEmpty) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Action") + .font(.subheadline.weight(.medium)) + Picker("Action", selection: $selectedActionString) { + ForEach(GhosttyLeaderActionCatalog.Section.allCases, id: \.self) { section in + SwiftUI.Section(section.rawValue) { + ForEach(GhosttyLeaderActionCatalog.actions(in: section), id: \.ghosttyActionString) { action in + Text(action.displayName).tag(action.ghosttyActionString) + } + } + } + } + .labelsHidden() + .help("The built-in action this sequence runs. Only host-routable actions are available.") + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { onCancel() } + .help("Discard this sequence without saving.") + Button("Save") { save() } + .keyboardShortcut(.defaultAction) + .disabled(!canSave) + .help("Save this leader sequence.") + } + } + .padding(20) + .frame(minWidth: 360) + } + + private func save() { + guard !keyStrokes.isEmpty else { return } + let fallback = GhosttyLeaderActionCatalog.all.first ?? .newTab + let action = GhosttyLeaderActionCatalog.action(forActionString: selectedActionString) ?? fallback + let sequence = LeaderKeySequence( + id: existing?.id ?? UUID(), + keyStrokes: keyStrokes, + target: .ghostty(action), + ) + onSave(sequence) + } +} + +// MARK: - Stroke field. + +// Renders the captured strokes as keycaps and, while capturing, hosts the +// no-modifier key recorder. Mirrors the keycap presentation of the single-chord +// recorder so sequence labels read consistently. +private struct SequenceStrokeField: View { + let keyStrokes: [SequenceKeyStroke] + let isCapturing: Bool + let onRecordStroke: (SequenceKeyStroke) -> Void + let onDeleteLast: () -> Void + let onFinish: () -> Void + + var body: some View { + HStack(spacing: 6) { + if keyStrokes.isEmpty { + Text("No keys recorded") + .foregroundStyle(.tertiary) + } else { + ForEach(Array(keyStrokes.enumerated()), id: \.offset) { _, stroke in + ForEach(Array(stroke.displaySymbols.enumerated()), id: \.offset) { _, symbol in + Keycap(symbol: symbol) + } + } + } + Spacer() + if isCapturing { + Image(systemName: "record.circle") + .foregroundStyle(.red) + .accessibilityLabel("Recording") + } + } + .frame(minHeight: 36) + .padding(.horizontal, 10) + .background(.quaternary, in: .rect(cornerRadius: 8)) + .background { + if isCapturing { + SequenceKeyRecorderRepresentable( + onRecordStroke: onRecordStroke, + onDeleteLast: onDeleteLast, + onFinish: onFinish, + ) + .frame(width: 0, height: 0) + } + } + } +} + +// MARK: - NSViewRepresentable for continuation-key capture. + +private struct SequenceKeyRecorderRepresentable: NSViewRepresentable { + var onRecordStroke: (SequenceKeyStroke) -> Void + var onDeleteLast: () -> Void + var onFinish: () -> Void + + func makeNSView(context: Context) -> SequenceKeyRecorderNSView { + let view = SequenceKeyRecorderNSView() + view.onRecordStroke = onRecordStroke + view.onDeleteLast = onDeleteLast + view.onFinish = onFinish + return view + } + + func updateNSView(_ nsView: SequenceKeyRecorderNSView, context: Context) { + nsView.onRecordStroke = onRecordStroke + nsView.onDeleteLast = onDeleteLast + nsView.onFinish = onFinish + } +} + +// MARK: - NSView for continuation-key capture. + +// Unlike `HotkeyRecorderNSView` (leader chord), continuation strokes do not +// require a modifier: a bare `w` is a valid stroke. Escape finishes capture +// (it is never recorded as a stroke, which also keeps it out of the reserved +// `escape=end_key_sequence` cancel slot) and Backspace deletes the last +// stroke (design D5). +final class SequenceKeyRecorderNSView: NSView { + var onRecordStroke: ((SequenceKeyStroke) -> Void)? + var onDeleteLast: (() -> Void)? + var onFinish: (() -> Void)? + + override var acceptsFirstResponder: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.makeFirstResponder(self) + } + + // Intercept key equivalents so menu shortcuts and the sheet's default button + // can't fire (or swallow keys) while recording. + override func performKeyEquivalent(with event: NSEvent) -> Bool { + keyDown(with: event) + return true + } + + override func keyDown(with event: NSEvent) { + let keyCode = event.keyCode + + if keyCode == UInt16(kVK_Escape) { + onFinish?() + return + } + + if keyCode == UInt16(kVK_Delete) { + onDeleteLast?() + return + } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + var modifiers: AppShortcutOverride.ModifierFlags = [] + if flags.contains(.command) { modifiers.insert(.command) } + if flags.contains(.option) { modifiers.insert(.option) } + if flags.contains(.control) { modifiers.insert(.control) } + if flags.contains(.shift) { modifiers.insert(.shift) } + + // Store no-modifier strokes as `nil` so the model's optional reads cleanly; + // `modifiers ?? []` treats them identically. + let stroke = SequenceKeyStroke(keyCode: keyCode, modifiers: modifiers.isEmpty ? nil : modifiers) + onRecordStroke?(stroke) + } +} diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 063239cc5..85f94e7bc 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -298,4 +298,132 @@ struct AppShortcutsTests { #expect(effective != nil) #expect(effective?.ghosttyKeybind == override.ghosttyKeybind) } + + // MARK: - Leader-key sequence lowering. + + // ⌘K leader, reused across the lowering cases. Its Ghostty token is `super+k`. + private static let leaderChord = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + + @Test func leaderKeyLoweringEmitsSequencesAndExactlyOneCancelBind() { + let config = LeaderKeyConfig( + leaderChord: Self.leaderChord, + sequences: [ + LeaderKeySequence( + keyStrokes: [ + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W)), + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_C)), + ], + target: .ghostty(.toggleCommandPalette), + ), + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_G))], + target: .ghostty(.gotoTab(index: 3)), + ), + ], + ) + + // The full emitted argument list, pinned exactly: each sequence lowers to + // `>k1>k2=` (leader token first, `>` separator), and a single + // cancel bind is appended last. + expectNoDifference( + AppShortcuts.leaderKeyGhosttyKeybindArguments(from: config), + [ + "--keybind=super+k>w>c=toggle_command_palette", + "--keybind=super+k>g=goto_tab:3", + "--keybind=super+k>escape=end_key_sequence", + ], + ) + } + + @Test func leaderKeyLoweringCancelBindUsesSequenceSeparatorBeforeEscape() { + let config = LeaderKeyConfig( + leaderChord: Self.leaderChord, + sequences: [ + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_T))], + target: .ghostty(.newTab), + ) + ], + ) + + let arguments = AppShortcuts.leaderKeyGhosttyKeybindArguments(from: config) + + // REQ-004: the cancel bind is the leader token, then the `>` sequence + // separator, then `escape` — NOT a single `escape` chord. There must + // be exactly one regardless of how many sequences are present. + #expect(arguments.contains("--keybind=super+k>escape=end_key_sequence")) + #expect(arguments.filter { $0.hasSuffix("=end_key_sequence") }.count == 1) + // Guard against the wrong form (no separator before escape). + #expect(arguments.contains("--keybind=super+kescape=end_key_sequence") == false) + } + + @Test func leaderKeyLoweringEmitsSingleCancelBindAcrossManySequences() { + let config = LeaderKeyConfig( + leaderChord: Self.leaderChord, + sequences: [ + LeaderKeySequence(keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_T))], target: .ghostty(.newTab)), + LeaderKeySequence(keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_X))], target: .ghostty(.closeTab)), + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_E))], + target: .ghostty(.equalizeSplits), + ), + ], + ) + + let arguments = AppShortcuts.leaderKeyGhosttyKeybindArguments(from: config) + #expect(arguments.filter { $0.hasSuffix("=end_key_sequence") }.count == 1) + #expect(arguments.count == 4) + } + + @Test func leaderKeyLoweringPinsParameterizedActionStrings() { + let config = LeaderKeyConfig( + leaderChord: Self.leaderChord, + sequences: [ + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_G))], + target: .ghostty(.gotoTab(index: 4)), + ), + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_M))], + target: .ghostty(.moveTab(offset: -1)), + ), + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_R))], + target: .ghostty(.resizeSplit(direction: .down, amount: 10)), + ), + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_S))], + target: .ghostty(.newSplit(direction: .right)), + ), + ], + ) + + let arguments = AppShortcuts.leaderKeyGhosttyKeybindArguments(from: config) + #expect(arguments.contains("--keybind=super+k>g=goto_tab:4")) + #expect(arguments.contains("--keybind=super+k>m=move_tab:-1")) + #expect(arguments.contains("--keybind=super+k>r=resize_split:down,10")) + #expect(arguments.contains("--keybind=super+k>s=new_split:right")) + } + + @Test func leaderKeyLoweringIsEmptyWhenNoLeaderConfigured() { + // No leader configured at all -> no leader args, so the single-chord + // arguments stay byte-for-byte unchanged (REQ-003). + expectNoDifference(AppShortcuts.leaderKeyGhosttyKeybindArguments(from: nil), []) + } + + @Test func leaderKeyLoweringIsEmptyWhenLeaderHasNoSequences() { + let config = LeaderKeyConfig(leaderChord: Self.leaderChord, sequences: []) + // A leader with no sequences emits nothing — not even a lone cancel bind that + // would shadow the leader chord. + expectNoDifference(AppShortcuts.leaderKeyGhosttyKeybindArguments(from: config), []) + } + + @Test func ghosttyCLIArgumentsUnchangedWhenLeaderIsNil() { + // The leader output is appended after the single-chord arguments, so with no + // leader the existing argument list is identical (REQ-003). + let overrides: [AppShortcutID: AppShortcutOverride] = [:] + let singleChordArguments = AppShortcuts.ghosttyCLIKeybindArguments(from: overrides) + let leaderArguments = AppShortcuts.leaderKeyGhosttyKeybindArguments(from: nil) + expectNoDifference(singleChordArguments + leaderArguments, singleChordArguments) + } } diff --git a/supacodeTests/GlobalSettingsDecodeTests.swift b/supacodeTests/GlobalSettingsDecodeTests.swift new file mode 100644 index 000000000..f2541217b --- /dev/null +++ b/supacodeTests/GlobalSettingsDecodeTests.swift @@ -0,0 +1,112 @@ +import Carbon.HIToolbox +import Foundation +import Testing + +@testable import SupacodeSettingsShared + +// Backward-compatibility coverage for the additive `GlobalSettings.leaderKey` +// field (T2 / REQ-007): an existing settings file that predates the feature has +// no `leaderKey` key at all, so it must decode to `leaderKey == nil` with its +// single-chord `shortcutOverrides` untouched, and a re-encode/decode must be +// stable. Also pins the resilient lossy decode at the `GlobalSettings` level. +@MainActor +struct GlobalSettingsDecodeTests { + // The three keys `GlobalSettings.init(from:)` requires; everything else falls + // back to a default when absent, mirroring a minimal pre-feature file. + private static func legacyJSON(shortcutOverridesJSON: String) -> String { + """ + { + "appearanceMode": "dark", + "updatesAutomaticallyCheckForUpdates": false, + "updatesAutomaticallyDownloadUpdates": true, + "shortcutOverrides": \(shortcutOverridesJSON) + } + """ + } + + private func encodedJSON(_ value: some Encodable) throws -> String { + try #require(String(bytes: try JSONEncoder().encode(value), encoding: .utf8)) + } + + // MARK: - Missing leaderKey decodes to nil. + + @Test func oldFileWithoutLeaderKeyDecodesToNilWithSingleChordsIntact() throws { + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + let overrides: [AppShortcutID: AppShortcutOverride] = [.newWorktree: override] + let json = Self.legacyJSON(shortcutOverridesJSON: try encodedJSON(overrides)) + + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: Data(json.utf8)) + + // The feature was absent from the file, so no leader is configured... + #expect(decoded.leaderKey == nil) + // ...and the pre-existing single-chord override survives untouched (no migration). + #expect(decoded.shortcutOverrides == overrides) + } + + @Test func decodedDefaultLeaderKeyIsNil() throws { + // A file with no shortcut overrides and no leader key still decodes cleanly. + let json = Self.legacyJSON(shortcutOverridesJSON: "{}") + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: Data(json.utf8)) + #expect(decoded.leaderKey == nil) + #expect(decoded.shortcutOverrides.isEmpty) + } + + // MARK: - Round-trip stability. + + @Test func leaderKeyRoundTripsThroughGlobalSettings() throws { + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + var settings = GlobalSettings.default + settings.leaderKey = LeaderKeyConfig( + leaderChord: leader, + sequences: [ + LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + ], + ) + + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + + #expect(decoded.leaderKey == settings.leaderKey) + } + + @Test func nilLeaderKeyRoundTripsStable() throws { + var settings = GlobalSettings.default + settings.leaderKey = nil + + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + + #expect(decoded.leaderKey == nil) + } + + // MARK: - Resilient lossy decode at the GlobalSettings level. + + @Test func malformedSequenceEntryInLeaderKeyDropsOnlyThatEntry() throws { + // A hand-edited file with one bad sequence (empty strokes) must not nuke the + // whole leader config or the valid sequence (REQ-006); the lossy decode lives + // in `LeaderKeyConfig` and is reached through `GlobalSettings.init(from:)`. + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + let json = """ + { + "appearanceMode": "system", + "updatesAutomaticallyCheckForUpdates": false, + "updatesAutomaticallyDownloadUpdates": false, + "leaderKey": { + "leaderChord": \(try encodedJSON(leader)), + "sequences": [ + { "keyStrokes": [], "target": { "kind": "ghostty", "ghostty": { "newTab": {} } } }, + { "keyStrokes": [{ "keyCode": 13 }], "target": { "kind": "ghostty", "ghostty": { "closeTab": {} } } } + ] + } + } + """ + + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: Data(json.utf8)) + + #expect(decoded.leaderKey?.sequences.count == 1) + #expect(decoded.leaderKey?.sequences.first?.target == .ghostty(.closeTab)) + } +} diff --git a/supacodeTests/LeaderKeyConflictValidatorTests.swift b/supacodeTests/LeaderKeyConflictValidatorTests.swift new file mode 100644 index 000000000..f07d57eb1 --- /dev/null +++ b/supacodeTests/LeaderKeyConflictValidatorTests.swift @@ -0,0 +1,136 @@ +import Carbon.HIToolbox +import Foundation +import Testing + +@testable import SupacodeSettingsShared + +// Covers the pure leader-key conflict validator (T4): prefix / duplicate relations +// over the sequence trie, sequence-vs-single-chord collisions, the Escape +// reserved-cancel-key concern, and leader-chord vs single-chord / reserved +// collisions. Assertions target the Equatable `LeaderKeyConflict` cases rather +// than their rendered messages so they survive copy changes (REQ-005). +@MainActor +struct LeaderKeyConflictValidatorTests { + // ⌘K leader anchor; its display string is "⌘K". + private static let leaderChord = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + + private func validate( + _ config: LeaderKeyConfig?, + overrides: [AppShortcutID: AppShortcutOverride] = [:], + reserved: Set = [], + ) -> LeaderKeyConflictReport { + LeaderKeyConflictValidator.validate( + config: config, + shortcutOverrides: overrides, + reservedDisplayStrings: reserved, + ) + } + + // MARK: - Prefix collisions. + + @Test func prefixSequenceFlagsBothDirections() { + // ` w` is a proper prefix of ` w c`, so the shorter fires + // first (it can never let the longer complete) and the longer can never fire. + let shorter = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + let longer = LeaderKeySequence( + keyStrokes: [ + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W)), + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_C)), + ], + target: .ghostty(.closeTab), + ) + + let report = validate(LeaderKeyConfig(leaderChord: Self.leaderChord, sequences: [shorter, longer])) + + #expect(report.conflicts(for: shorter.id) == [.prefixOfAnotherSequence(other: "⌘K W C")]) + #expect(report.conflicts(for: longer.id) == [.prefixedByAnotherSequence(other: "⌘K W")]) + } + + @Test func exactDuplicateSequenceIsFlaggedOnBoth() { + let first = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + let second = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.closeTab), + ) + + let report = validate(LeaderKeyConfig(leaderChord: Self.leaderChord, sequences: [first, second])) + + #expect(report.conflicts(for: first.id) == [.duplicateSequence]) + #expect(report.conflicts(for: second.id) == [.duplicateSequence]) + } + + // MARK: - Sequence vs single chord. + + @Test func sequenceStrokeCollidingWithSingleChordIsFlagged() { + // A single `⌘N` continuation has the same display path as the built-in + // New Worktree chord, so the same chord means two things by context. + let sequence = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_N), modifiers: [.command])], + target: .ghostty(.newTab), + ) + + let report = validate(LeaderKeyConfig(leaderChord: Self.leaderChord, sequences: [sequence])) + + #expect(report.conflicts(for: sequence.id) == [.collidesWithShortcut(chord: "⌘N", shortcut: "New Worktree")]) + } + + @Test func escapeFirstSequenceFlagsReservedCancelKey() { + // Escape after the leader is reserved for the auto-bound + // `escape=end_key_sequence` cancel, so a sequence starting with + // Escape can never fire. + let sequence = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_Escape))], + target: .ghostty(.newTab), + ) + + let report = validate(LeaderKeyConfig(leaderChord: Self.leaderChord, sequences: [sequence])) + + #expect(report.conflicts(for: sequence.id) == [.usesReservedCancelKey]) + } + + // MARK: - Leader chord collisions. + + @Test func leaderCollidingWithSingleChordIsFlaggedAtConfigLevel() { + // ⌘N is the New Worktree chord; choosing it as the leader collides. + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_N), modifiers: [.command]) + + let report = validate(LeaderKeyConfig(leaderChord: leader, sequences: [])) + + #expect(report.leaderConflicts == [.leaderCollidesWithShortcut(chord: "⌘N", shortcut: "New Worktree")]) + } + + @Test func leaderInReservedSetIsFlaggedAtConfigLevel() { + // The reserved set is injected; passing the leader's own display string in it + // is the deterministic stand-in for a system/terminal-reserved chord. + let report = validate( + LeaderKeyConfig(leaderChord: Self.leaderChord, sequences: []), + reserved: [Self.leaderChord.displayString], + ) + + #expect(report.leaderConflicts == [.leaderReserved(chord: "⌘K")]) + } + + // MARK: - Clean configurations. + + @Test func nonConflictingConfigurationProducesEmptyReport() { + let sequence = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + + let report = validate(LeaderKeyConfig(leaderChord: Self.leaderChord, sequences: [sequence])) + + #expect(report.isEmpty) + #expect(report.conflicts(for: sequence.id).isEmpty) + } + + @Test func nilConfigurationProducesEmptyReport() { + #expect(validate(nil).isEmpty) + } +} diff --git a/supacodeTests/LeaderKeySequenceTests.swift b/supacodeTests/LeaderKeySequenceTests.swift new file mode 100644 index 000000000..f3c9e164f --- /dev/null +++ b/supacodeTests/LeaderKeySequenceTests.swift @@ -0,0 +1,195 @@ +import Carbon.HIToolbox +import CustomDump +import Foundation +import Testing + +@testable import SupacodeSettingsShared + +// Covers the persisted leader-key model (T1): Codable round-trip for the config, +// sequence, stroke, and target types; the lossy-decode contract that drops a +// single malformed `sequences` entry while keeping the valid ones; and the +// foundation-scope guarantee that only a `GhosttyLeaderAction` is a representable +// / lowerable target. +@MainActor +struct LeaderKeySequenceTests { + // MARK: - Helpers. + + // A leader chord (⌘K) used as the anchor for the round-trip fixtures. + private static let leaderChord = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + + private func roundTrip(_ value: T) throws -> T { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(T.self, from: data) + } + + // MARK: - Codable round-trip. + + @Test func sequenceKeyStrokeRoundTripsWithAndWithoutModifiers() throws { + let unmodified = SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W)) + let modified = SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_C), modifiers: [.shift]) + + expectNoDifference(try roundTrip(unmodified), unmodified) + expectNoDifference(try roundTrip(modified), modified) + } + + @Test func leaderActionTargetRoundTripsForEveryParameterizedCase() throws { + let targets: [LeaderActionTarget] = [ + .ghostty(.newTab), + .ghostty(.closeTab), + .ghostty(.gotoTab(index: 3)), + .ghostty(.moveTab(offset: -1)), + .ghostty(.toggleCommandPalette), + .ghostty(.newSplit(direction: .right)), + .ghostty(.gotoSplit(direction: .previous)), + .ghostty(.resizeSplit(direction: .down, amount: 10)), + .ghostty(.equalizeSplits), + .ghostty(.toggleSplitZoom), + ] + + for target in targets { + expectNoDifference(try roundTrip(target), target) + } + } + + @Test func leaderKeySequenceRoundTripsPreservingIDStrokesAndTarget() throws { + let sequence = LeaderKeySequence( + keyStrokes: [ + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W)), + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_C)), + ], + target: .ghostty(.toggleCommandPalette), + ) + + let decoded = try roundTrip(sequence) + expectNoDifference(decoded, sequence) + // The stable id must survive the round-trip so edit/upsert by id keeps working. + #expect(decoded.id == sequence.id) + } + + @Test func leaderKeyConfigRoundTripsLeaderChordAndSequences() throws { + let config = LeaderKeyConfig( + leaderChord: Self.leaderChord, + sequences: [ + LeaderKeySequence(keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_T))], target: .ghostty(.newTab)), + LeaderKeySequence( + keyStrokes: [ + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_S)), + SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_L)), + ], + target: .ghostty(.newSplit(direction: .left)), + ), + ], + ) + + expectNoDifference(try roundTrip(config), config) + } + + // MARK: - Lossy decode (malformed entry dropped, valid ones survive). + + @Test func malformedSequenceEntryIsDroppedWhileValidEntriesSurvive() throws { + // The middle entry has an empty `keyStrokes` array, which `LeaderKeySequence` + // rejects on decode; the enclosing `sequences` array must drop only that + // entry and keep the two valid ones (REQ-006). + let json = """ + { + "leaderChord": \(try Self.leaderChordJSON()), + "sequences": [ + { "keyStrokes": [{ "keyCode": 17 }], "target": { "kind": "ghostty", "ghostty": { "newTab": {} } } }, + { "keyStrokes": [], "target": { "kind": "ghostty", "ghostty": { "newTab": {} } } }, + { "keyStrokes": [{ "keyCode": 1 }], "target": { "kind": "ghostty", "ghostty": { "closeTab": {} } } } + ] + } + """ + + let config = try JSONDecoder().decode(LeaderKeyConfig.self, from: Data(json.utf8)) + + #expect(config.sequences.count == 2) + expectNoDifference(config.sequences.map(\.target), [.ghostty(.newTab), .ghostty(.closeTab)]) + } + + @Test func unknownTargetKindDropsThatEntryNotTheWholeConfig() throws { + // A sequence whose target carries an unknown discriminator (a case a future + // build might write) fails to decode; only that entry is dropped, the valid + // one survives, and launch is never aborted (D11 forward-compat seam). + let json = """ + { + "leaderChord": \(try Self.leaderChordJSON()), + "sequences": [ + { "keyStrokes": [{ "keyCode": 17 }], "target": { "kind": "appShortcut", "appShortcut": "newWorktree" } }, + { "keyStrokes": [{ "keyCode": 1 }], "target": { "kind": "ghostty", "ghostty": { "closeTab": {} } } } + ] + } + """ + + let config = try JSONDecoder().decode(LeaderKeyConfig.self, from: Data(json.utf8)) + + #expect(config.sequences.count == 1) + expectNoDifference(config.sequences.first?.target, .ghostty(.closeTab)) + } + + @Test func emptyKeyStrokesSequenceDecodeThrows() throws { + // Direct decode of a single sequence with no strokes throws (it is the + // `Lossy` wrapper that turns this throw into a dropped array element). + let json = """ + { "keyStrokes": [], "target": { "kind": "ghostty", "ghostty": { "newTab": {} } } } + """ + + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(LeaderKeySequence.self, from: Data(json.utf8)) + } + } + + @Test func unknownTargetKindDecodeThrows() throws { + let json = """ + { "kind": "appShortcut", "appShortcut": "newWorktree" } + """ + + #expect(throws: (any Error).self) { + try JSONDecoder().decode(LeaderActionTarget.self, from: Data(json.utf8)) + } + } + + // MARK: - Foundation-scope target representability. + + @Test func onlyGhosttyTargetsAreRepresentableAndLowerable() { + // The v1 target sum type exposes exactly one constructor (`.ghostty`), and + // every value resolves to a concrete Ghostty built-in via `ghosttyAction`. + // There is no way to express a menu-only app action, so a sequence can never + // be bound to a target that would silently no-op (HYP-001 / D2). + let target = LeaderActionTarget.ghostty(.gotoTab(index: 2)) + #expect(target.ghosttyAction == .gotoTab(index: 2)) + } + + // MARK: - Parameterized action grammar pins. + + @Test func ghosttyActionStringsArePinnedToKeybindGrammar() { + let cases: [(GhosttyLeaderAction, String)] = [ + (.newTab, "new_tab"), + (.closeTab, "close_tab"), + (.gotoTab(index: 4), "goto_tab:4"), + (.moveTab(offset: -1), "move_tab:-1"), + (.toggleCommandPalette, "toggle_command_palette"), + (.newSplit(direction: .up), "new_split:up"), + (.newSplit(direction: .right), "new_split:right"), + (.gotoSplit(direction: .previous), "goto_split:previous"), + (.gotoSplit(direction: .next), "goto_split:next"), + (.resizeSplit(direction: .down, amount: 10), "resize_split:down,10"), + (.equalizeSplits, "equalize_splits"), + (.toggleSplitZoom, "toggle_split_zoom"), + ] + + for (action, expected) in cases { + #expect(action.ghosttyActionString == expected) + } + } + + // MARK: - JSON fixtures. + + // The encoded leader chord, reused inside the hand-written `sequences` JSON + // fixtures so the malformed-entry tests exercise the real `AppShortcutOverride` + // decode path rather than a hard-coded chord shape. + private static func leaderChordJSON() throws -> String { + let data = try JSONEncoder().encode(leaderChord) + return try #require(String(bytes: data, encoding: .utf8)) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 89b68e5a2..21642eab9 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -582,6 +582,167 @@ struct SettingsFeatureTests { #expect(settingsFile.global.shortcutOverrides.isEmpty) } + // MARK: - Leader-key configuration. + + @Test(.dependencies) func updateLeaderChordCreatesConfigWhenNoneExists() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } + + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + await store.send(.updateLeaderChord(leader)) { + $0.leaderKey = LeaderKeyConfig(leaderChord: leader) + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.leaderKey == LeaderKeyConfig(leaderChord: leader)) + } + + @Test(.dependencies) func updateLeaderChordPreservesExistingSequences() async { + let oldLeader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + let sequence = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + var initialSettings = GlobalSettings.default + initialSettings.leaderKey = LeaderKeyConfig(leaderChord: oldLeader, sequences: [sequence]) + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + // Changing the leader chord re-homes the existing sequences onto the new + // leader rather than dropping them (REQ-001 AC3). + let newLeader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_J), modifiers: [.command]) + await store.send(.updateLeaderChord(newLeader)) { + $0.leaderKey?.leaderChord = newLeader + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.leaderKey?.leaderChord == newLeader) + #expect(settingsFile.global.leaderKey?.sequences == [sequence]) + } + + @Test(.dependencies) func updateLeaderSequenceAddsNewSequence() async { + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + var initialSettings = GlobalSettings.default + initialSettings.leaderKey = LeaderKeyConfig(leaderChord: leader) + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + let sequence = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + await store.send(.updateLeaderSequence(sequence)) { + $0.leaderKey?.sequences = [sequence] + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.leaderKey?.sequences == [sequence]) + } + + @Test(.dependencies) func updateLeaderSequenceEditsExistingSequenceByID() async { + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + let original = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + var initialSettings = GlobalSettings.default + initialSettings.leaderKey = LeaderKeyConfig(leaderChord: leader, sequences: [original]) + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + // Same id, new target -> upsert edits in place rather than appending. + let edited = LeaderKeySequence( + id: original.id, + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_C))], + target: .ghostty(.closeTab), + ) + await store.send(.updateLeaderSequence(edited)) { + $0.leaderKey?.sequences = [edited] + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.leaderKey?.sequences == [edited]) + } + + @Test(.dependencies) func updateLeaderSequenceWithoutLeaderIsNoOp() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } + + // A sequence is anchored to a leader; with none configured there is nothing + // to attach it to, so the arm is a no-op (no state change, no persist). + let sequence = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + await store.send(.updateLeaderSequence(sequence)) + #expect(settingsFile.global.leaderKey == nil) + } + + @Test(.dependencies) func removeLeaderSequenceKeepsLeaderAndOtherSequences() async { + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + let keep = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + let remove = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_C))], + target: .ghostty(.closeTab), + ) + var initialSettings = GlobalSettings.default + initialSettings.leaderKey = LeaderKeyConfig(leaderChord: leader, sequences: [keep, remove]) + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.removeLeaderSequence(remove.id)) { + $0.leaderKey?.sequences = [keep] + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.leaderKey?.leaderChord == leader) + #expect(settingsFile.global.leaderKey?.sequences == [keep]) + } + + @Test(.dependencies) func resetLeaderKeyClearsEntireConfig() async { + let leader = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) + let sequence = LeaderKeySequence( + keyStrokes: [SequenceKeyStroke(keyCode: UInt16(kVK_ANSI_W))], + target: .ghostty(.newTab), + ) + var initialSettings = GlobalSettings.default + initialSettings.leaderKey = LeaderKeyConfig(leaderChord: leader, sequences: [sequence]) + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.resetLeaderKey) { + $0.leaderKey = nil + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.leaderKey == nil) + } + // MARK: - Toggle shortcut enabled. @Test(.dependencies) func toggleShortcutDisabledInsertsDisabledSentinel() async {