Skip to content
Open
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
48 changes: 39 additions & 9 deletions leanring-buddy/BuddyDictationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import Foundation
import Speech

enum BuddyPushToTalkShortcut {
enum ShortcutOption {
case shiftFunction
case controlOption
case shiftControl
case controlOptionSpace
case shiftControlSpace
/// The modifier key combinations available for the push-to-talk shortcut.
/// Backed by String raw values so the user's choice can be persisted to UserDefaults.
enum ShortcutOption: String, CaseIterable {
case shiftFunction = "shiftFunction"
case controlOption = "controlOption"
case shiftControl = "shiftControl"
case controlOptionSpace = "controlOptionSpace"
case shiftControlSpace = "shiftControlSpace"

var displayText: String {
switch self {
Expand All @@ -36,6 +38,22 @@ enum BuddyPushToTalkShortcut {
}
}

/// Human-readable label for the shortcut picker UI in the companion panel.
var pickerLabel: String {
switch self {
case .shiftFunction:
return "Shift + Fn"
case .controlOption:
return "Ctrl + Option"
case .shiftControl:
return "Shift + Control"
case .controlOptionSpace:
return "Ctrl + Option + Space"
case .shiftControlSpace:
return "Shift + Control + Space"
}
}

var keyCapsuleLabels: [String] {
switch self {
case .shiftFunction:
Expand Down Expand Up @@ -92,10 +110,22 @@ enum BuddyPushToTalkShortcut {
case keyUp
}

static let currentShortcutOption: ShortcutOption = .controlOption
/// The UserDefaults key used to persist the user's chosen push-to-talk shortcut.
static let pushToTalkShortcutUserDefaultsKey = "pushToTalkShortcutOption"

/// Reads the user's chosen shortcut from UserDefaults, falling back to
/// Ctrl+Option if no preference has been saved yet.
static var currentShortcutOption: ShortcutOption {
guard let savedRawValue = UserDefaults.standard.string(forKey: pushToTalkShortcutUserDefaultsKey),
let savedOption = ShortcutOption(rawValue: savedRawValue) else {
return .controlOption
}
return savedOption
}

static let pushToTalkKeyCode: UInt16 = 49 // Space
static let pushToTalkDisplayText = currentShortcutOption.displayText
static let pushToTalkTooltipText = "push to talk (\(pushToTalkDisplayText))"
static var pushToTalkDisplayText: String { currentShortcutOption.displayText }
static var pushToTalkTooltipText: String { "push to talk (\(pushToTalkDisplayText))" }

static func shortcutTransition(
for event: NSEvent,
Expand Down
12 changes: 12 additions & 0 deletions leanring-buddy/CompanionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ final class CompanionManager: ObservableObject {
/// Used by the panel to show accurate status text ("Active" vs "Ready").
@Published private(set) var isOverlayVisible: Bool = false

/// The user's chosen push-to-talk modifier combination. Persisted to UserDefaults
/// so it survives app restarts. Defaults to Ctrl+Option (the original hardcoded shortcut).
@Published var selectedPushToTalkModifierCombination: BuddyPushToTalkShortcut.ShortcutOption = BuddyPushToTalkShortcut.currentShortcutOption

func setSelectedPushToTalkModifierCombination(_ newShortcutOption: BuddyPushToTalkShortcut.ShortcutOption) {
selectedPushToTalkModifierCombination = newShortcutOption
UserDefaults.standard.set(newShortcutOption.rawValue, forKey: BuddyPushToTalkShortcut.pushToTalkShortcutUserDefaultsKey)
// Reset the monitor's pressed state so the old shortcut doesn't get stuck
// in the "pressed" position when the user switches to a new combination.
globalPushToTalkShortcutMonitor.resetShortcutPressedState()
}

/// The Claude model used for voice responses. Persisted to UserDefaults.
@Published var selectedModel: String = UserDefaults.standard.string(forKey: "selectedClaudeModel") ?? "claude-sonnet-4-6"

Expand Down
59 changes: 58 additions & 1 deletion leanring-buddy/CompanionPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ struct CompanionPanelView: View {
Spacer()
.frame(height: 12)

pushToTalkShortcutPickerRow
.padding(.horizontal, 16)

Spacer()
.frame(height: 4)

modelPickerRow
.padding(.horizontal, 16)
}
Expand Down Expand Up @@ -127,7 +133,7 @@ struct CompanionPanelView: View {
@ViewBuilder
private var permissionsCopySection: some View {
if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted {
Text("Hold Control+Option to talk.")
Text("Hold \(companionManager.selectedPushToTalkModifierCombination.pickerLabel) to talk.")
.font(.system(size: 12, weight: .medium))
.foregroundColor(DS.Colors.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down Expand Up @@ -596,6 +602,57 @@ struct CompanionPanelView: View {
.padding(.vertical, 4)
}

// MARK: - Push-to-Talk Shortcut Picker

private var pushToTalkShortcutPickerRow: some View {
HStack {
Text("Shortcut")
.font(.system(size: 13, weight: .medium))
.foregroundColor(DS.Colors.textSecondary)

Spacer()

Menu {
ForEach(BuddyPushToTalkShortcut.ShortcutOption.allCases, id: \.self) { shortcutOption in
Button(action: {
companionManager.setSelectedPushToTalkModifierCombination(shortcutOption)
}) {
HStack {
Text(shortcutOption.pickerLabel)
if shortcutOption == companionManager.selectedPushToTalkModifierCombination {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(companionManager.selectedPushToTalkModifierCombination.pickerLabel)
.font(.system(size: 11, weight: .medium))
.foregroundColor(DS.Colors.textPrimary)

Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
.foregroundColor(DS.Colors.textTertiary)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.white.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.stroke(DS.Colors.borderSubtle, lineWidth: 0.5)
)
}
.menuStyle(.borderlessButton)
.fixedSize()
.pointerCursor()
}
.padding(.vertical, 4)
}

// MARK: - Model Picker

private var modelPickerRow: some View {
Expand Down
9 changes: 9 additions & 0 deletions leanring-buddy/GlobalPushToTalkShortcutMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject {
stop()
}

/// Resets the pressed state when the user changes the push-to-talk shortcut
/// in settings. Without this, switching shortcuts while the old one is held
/// would leave isShortcutCurrentlyPressed stuck as true.
func resetShortcutPressedState() {
guard isShortcutCurrentlyPressed else { return }
isShortcutCurrentlyPressed = false
shortcutTransitionPublisher.send(.released)
}

func start() {
// If the event tap is already running, don't restart it.
// Restarting resets isShortcutCurrentlyPressed, which would kill
Expand Down