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 {