Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -146,6 +152,7 @@ public struct SettingsFeature {
),
autoDeleteArchivedWorktreesAfterDays: autoDeleteArchivedWorktreesAfterDays,
shortcutOverrides: shortcutOverrides,
leaderKey: leaderKey,
globalScripts: globalScripts,
richAgentNotificationsEnabled: richAgentNotificationsEnabled,
agentPresenceBadgesEnabled: agentPresenceBadgesEnabled,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions SupacodeSettingsShared/App/AppShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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=<leader>>k1>k2=<action>`: 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=<leader>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] {
Expand Down
Loading