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
25 changes: 25 additions & 0 deletions leanring-buddy/CompanionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ final class CompanionManager: ObservableObject {
private var shortcutTransitionCancellable: AnyCancellable?
private var voiceStateCancellable: AnyCancellable?
private var audioPowerCancellable: AnyCancellable?
private var typingStateCancellable: AnyCancellable?
private var accessibilityCheckTimer: Timer?
private var pendingKeyboardShortcutStartTask: Task<Void, Never>?
/// Scheduled hide for transient cursor mode — cancelled if the user
Expand All @@ -107,6 +108,11 @@ final class CompanionManager: ObservableObject {
/// Used by the panel to show accurate status text ("Active" vs "Ready").
@Published private(set) var isOverlayVisible: Bool = false

/// True while the user is actively typing (non-PTT keypress detected).
/// Forwarded from GlobalPushToTalkShortcutMonitor and used by BlueCursorView
/// to hide the cursor during idle typing, mirroring macOS hide-on-type behavior.
@Published private(set) var isUserTyping: Bool = false

/// 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 Expand Up @@ -179,6 +185,7 @@ final class CompanionManager: ObservableObject {
bindVoiceStateObservation()
bindAudioPowerLevel()
bindShortcutTransitions()
bindTypingState()
// Eagerly touch the Claude API so its TLS warmup handshake completes
// well before the onboarding demo fires at ~40s into the video.
_ = claudeAPI
Expand Down Expand Up @@ -298,10 +305,18 @@ final class CompanionManager: ObservableObject {
shortcutTransitionCancellable?.cancel()
voiceStateCancellable?.cancel()
audioPowerCancellable?.cancel()
typingStateCancellable?.cancel()
accessibilityCheckTimer?.invalidate()
accessibilityCheckTimer = nil
}

/// Called by BlueCursorView's cursor-tracking timer when the mouse moves
/// after a period of typing, restoring the cursor — mirrors the "until mouse
/// moves" part of NSCursor.setHiddenUntilMouseMoves(true).
func resetUserTyping() {
isUserTyping = false
}

func refreshAllPermissions() {
let previouslyHadAccessibility = hasAccessibilityPermission
let previouslyHadScreenRecording = hasScreenRecordingPermission
Expand Down Expand Up @@ -470,6 +485,16 @@ final class CompanionManager: ObservableObject {
}
}

/// Forwards the monitor's isUserTyping flag onto CompanionManager so
/// BlueCursorView can observe a single source-of-truth for cursor hiding.
private func bindTypingState() {
typingStateCancellable = globalPushToTalkShortcutMonitor.$isUserTyping
.receive(on: DispatchQueue.main)
.sink { [weak self] typing in
self?.isUserTyping = typing
}
}

private func handleShortcutTransition(_ transition: BuddyPushToTalkShortcut.ShortcutTransition) {
switch transition {
case .pressed:
Expand Down
16 changes: 16 additions & 0 deletions leanring-buddy/GlobalPushToTalkShortcutMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject {
/// waiting for the async dictation state pipeline to catch up.
@Published private(set) var isShortcutCurrentlyPressed = false

/// True while the user is typing regular keys (non-PTT).
/// Used by BlueCursorView to hide the cursor overlay during typing,
/// mirroring macOS NSCursor.setHiddenUntilMouseMoves(true) behavior.
/// Reset to false when the mouse moves (detected in BlueCursorView's 16ms timer).
@Published private(set) var isUserTyping = false

deinit {
stop()
}
Expand Down Expand Up @@ -85,6 +91,7 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject {

func stop() {
isShortcutCurrentlyPressed = false
isUserTyping = false

if let globalEventTapRunLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), globalEventTapRunLoopSource, .commonModes)
Expand Down Expand Up @@ -127,6 +134,15 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject {
shortcutTransitionPublisher.send(.released)
}

// When a regular (non-PTT) key is pressed, signal that the user is typing
// so the cursor overlay hides — mirroring NSCursor.setHiddenUntilMouseMoves.
// The PTT shortcut (ctrl+option) uses .flagsChanged not .keyDown, so it
// never reaches this branch. We also guard against PTT-combo keydowns via
// shortcutTransition == .none to be safe.
if eventType == .keyDown && shortcutTransition == .none {
isUserTyping = true
}

return Unmanaged.passUnretained(event)
}
}
30 changes: 29 additions & 1 deletion leanring-buddy/OverlayWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,19 @@ struct BlueCursorView: View {
let localY = screenFrame.height - (mouseLocation.y - screenFrame.origin.y)
_cursorPosition = State(initialValue: CGPoint(x: localX + 35, y: localY + 25))
_isCursorOnThisScreen = State(initialValue: screenFrame.contains(mouseLocation))
// Seed typing-reset tracker from current mouse location so the first
// mouse move after a keypress is detected correctly from the start.
_lastMouseLocationForTypingDetection = State(initialValue: mouseLocation)
}
@State private var timer: Timer?
@State private var welcomeText: String = ""
@State private var showWelcome: Bool = true
@State private var bubbleSize: CGSize = .zero
@State private var bubbleOpacity: Double = 1.0
@State private var cursorOpacity: Double = 0.0
/// Tracks the previous mouse location so we can detect movement and reset
/// the typing-hide state — matching NSCursor.setHiddenUntilMouseMoves(true).
@State private var lastMouseLocationForTypingDetection: CGPoint = .zero

// MARK: - Buddy Navigation State

Expand Down Expand Up @@ -308,7 +314,7 @@ struct BlueCursorView: View {
.rotationEffect(.degrees(triangleRotationDegrees))
.shadow(color: DS.Colors.overlayCursorBlue, radius: 8 + (buddyFlightScale - 1.0) * 20, x: 0, y: 0)
.scaleEffect(buddyFlightScale)
.opacity(buddyIsVisibleOnThisScreen && (companionManager.voiceState == .idle || companionManager.voiceState == .responding) ? cursorOpacity : 0)
.opacity(buddyIsVisibleOnThisScreen && !shouldHideForTyping && (companionManager.voiceState == .idle || companionManager.voiceState == .responding) ? cursorOpacity : 0)
.position(cursorPosition)
.animation(
buddyNavigationMode == .followingCursor
Expand Down Expand Up @@ -386,6 +392,14 @@ struct BlueCursorView: View {
}
}

/// True when the cursor should be hidden because the user is typing in idle state.
/// Mirrors macOS NSCursor.setHiddenUntilMouseMoves(true) — hide on keypress,
/// reappear on mouse move. Never hides during voice interaction (listening/
/// processing/responding) so the waveform and spinner stay visible.
private var shouldHideForTyping: Bool {
companionManager.isUserTyping && companionManager.voiceState == .idle
}

/// Whether the buddy triangle should be visible on this screen.
/// True when cursor is on this screen during normal following, or
/// when navigating/pointing at a target on this screen. When another
Expand Down Expand Up @@ -413,6 +427,20 @@ struct BlueCursorView: View {
let mouseLocation = NSEvent.mouseLocation
self.isCursorOnThisScreen = self.screenFrame.contains(mouseLocation)

// When typing-hide is active, any mouse movement resets it —
// mirroring the "until mouse moves" part of NSCursor.setHiddenUntilMouseMoves.
// Runs before the navigation early-returns so it fires regardless of buddy mode.
if self.companionManager.isUserTyping {
let movementDistance = hypot(
mouseLocation.x - self.lastMouseLocationForTypingDetection.x,
mouseLocation.y - self.lastMouseLocationForTypingDetection.y
)
if movementDistance > 1.0 {
self.companionManager.resetUserTyping()
}
}
self.lastMouseLocationForTypingDetection = mouseLocation

// During forward flight or pointing, the buddy is NOT interrupted by
// mouse movement — it completes its full animation and return flight.
// Only during the RETURN flight do we allow cursor movement to cancel
Expand Down