diff --git a/app/modules/App/Sources/App.swift b/app/modules/App/Sources/App.swift index b938665eb..fc342d009 100644 --- a/app/modules/App/Sources/App.swift +++ b/app/modules/App/Sources/App.swift @@ -156,6 +156,11 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { var handleApplicationDidBecomeActive: (() -> Void)? + func applicationWillFinishLaunching(_: Notification) { + // Hide the dock icon - the app is only accessible via menu bar + NSApp.setActivationPolicy(.accessory) + } + func applicationDidBecomeActive(_: Notification) { isAppActive.send(true) diff --git a/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionView.swift b/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionView.swift index 3928a23ff..e33a21e01 100644 --- a/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionView.swift +++ b/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionView.swift @@ -18,7 +18,11 @@ struct CodeCompletionView: View { var body: some View { GeometryReader { geometry in ZStack(alignment: .topLeading) { - if let completion = viewModel.completion, let completionRequest = viewModel.completionTask?.request { + if + let completion = viewModel.completion, + let completionRequest = viewModel.completionTask?.request, + let verticalContentOffset = viewModel.verticalContentOffset + { if let screenshot = viewModel.screenshot, viewModel.isCompletionExpanded { VStack(alignment: .leading, spacing: 0) { Rectangle().frame(height: max( @@ -32,7 +36,7 @@ struct CodeCompletionView: View { Image(screenshot, scale: XcodeScreenshoter.retinaScale, label: Text("")) } .padding(.top, viewModel.lineHeight ?? 0) - .padding(.top, viewModel.verticalContentOffset) + .padding(.top, verticalContentOffset) } CompletionDiffView( @@ -52,7 +56,25 @@ struct CodeCompletionView: View { .padding(.trailing, viewModel.trailingContentOffset + 2) // 2 to not overlap with the scrollbar .frame(width: geometry.size.width) .fixedSize() - .padding(.top, viewModel.verticalContentOffset) + .padding(.top, verticalContentOffset) + } else if + viewModel.showChatTooltip, + let verticalContentOffset = viewModel.verticalContentOffset + { + // Show chat tooltip on the cursor line with trailing alignment + HStack { + Spacer() + Text(viewModel.showChatShortcutDisplay) + .frame(height: viewModel.lineHeight) + .padding(.horizontal, 3) + .with(cornerRadius: 6, backgroundColor: colorScheme.xcodeSidebarBackground) + .opacity(0.5) + } + .padding(.leading, viewModel.leadingContentOffset + 1) + .padding(.trailing, viewModel.trailingContentOffset + 2) + .frame(width: geometry.size.width) + .padding(.top, verticalContentOffset) + .transition(.asymmetric(insertion: .opacity.animation(.easeIn(duration: 0.3)), removal: .identity)) } else { // Empty state with minimal size Color.clear.frame(width: 1, height: 1) @@ -73,6 +95,9 @@ struct CodeCompletionView: View { .clipped() } } + + @Environment(\.colorScheme) private var colorScheme + } // MARK: - CompletionDiffView diff --git a/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionViewModel.swift b/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionViewModel.swift index c96949e96..fa0f89555 100644 --- a/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionViewModel.swift +++ b/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionViewModel.swift @@ -87,6 +87,12 @@ final class CodeCompletionViewModel { } }.store(in: &cancellables) for completionKeyHandler in completionKeyHandlers { completionKeyHandler.stop() } + + xcodeObservation = xcodeObserver.statePublisher.sink { @Sendable state in + Task { @MainActor [weak self] in + await self?.handleXcodeStateChange(state) + } + } } /// Indicates if code completion is enabled (i.e. service is available) @@ -115,6 +121,8 @@ final class CodeCompletionViewModel { private(set) var isCompletionExpanded = false private(set) var showAutomaticCompletionStatusMessage = false + /// Indicates whether to show the chat tooltip. + private(set) var showChatTooltip = false @ObservationIgnored private(set) var styledCompletionTask: Task? private(set) var styledCompletion: SyntaxHighlightedCompletion? @@ -125,6 +133,11 @@ final class CodeCompletionViewModel { /// Thread-safe state for CGEvent callbacks to check without blocking on main actor let keyEventState = KeyEventState() + /// The display string for the "show chat" keyboard shortcut. + var showChatShortcutDisplay: String { + "\(settingsService.value(for: \.keyboardShortcuts)[withDefault: .addContextToCurrentChat].display) to chat" + } + private(set) var isAutomaticCompletionEnabled = true { didSet { // Update thread-safe state for CGEvent callbacks @@ -132,11 +145,27 @@ final class CodeCompletionViewModel { } } + /// The line number that needs vertical offset adjustment (either to show tooltip or completion). + var lineThatNeedsVerticalOffset: Int? { + didSet { + if lineThatNeedsVerticalOffset != oldValue { + verticalContentOffset = nil + } + } + } + /// The offset between the top of the view and the top of the text being completed. - var verticalContentOffset: CGFloat = 0 { + var verticalContentOffset: CGFloat? = nil { didSet { if verticalContentOffset != oldValue { - screenShotEditorIfNeeded() + if completion != nil { + screenShotEditorIfNeeded() + } else if showChatTooltip, oldValue != nil { + // When changed, hide the tooltip instead of having jittery scrolling + chatTooltipTask?.cancel() + showChatTooltip = false + updateChatTooltipVisibility() // Reset a timer to show the tooltip + } } } } @@ -160,9 +189,17 @@ final class CodeCompletionViewModel { try Task.checkCancellation() self.styledCompletion = styledCompletion } + + // Hide chat tooltip when completion appears, but don't restart the delay + // (the delay is managed by editorState changes) + chatTooltipTask?.cancel() + showChatTooltip = false + lineThatNeedsVerticalOffset = completionTask?.request.selection.start.line } else { styledCompletionTask?.cancel() styledCompletion = nil + lineThatNeedsVerticalOffset = nil + updateChatTooltipVisibility() } } } @@ -196,7 +233,7 @@ final class CodeCompletionViewModel { } func apply(completion: CompletionSuggestion) async { - guard let completionTask, let editorState else { return } + guard let editorState else { return } // Convert completion suggestion to FileChange and apply using XcodeController do { @@ -299,10 +336,12 @@ final class CodeCompletionViewModel { private var xcodeObservation: AnyCancellable? private var statusMessageTask: Task? + private var chatTooltipTask: Task? private var editorState: EditorState? { didSet { keyEventState.update(hasEditorState: editorState != nil) + updateChatTooltipVisibility() } } @@ -320,14 +359,41 @@ final class CodeCompletionViewModel { } } + /// Updates the chat tooltip visibility based on the current editor state. + /// Shows the tooltip after a 1-second delay when there's an editor state but no completion. + private func updateChatTooltipVisibility() { + guard let editorState else { return } + guard completion == nil else { + showChatTooltip = false + return + } + if editorState.selection.start.line == lineThatNeedsVerticalOffset, chatTooltipTask != nil { + // Cursor line not changed and tooltip task already running + return + } + chatTooltipTask?.cancel() + showChatTooltip = false + lineThatNeedsVerticalOffset = editorState.selection.start.line + + // Show tooltip after 1 second delay + let file = editorState.fileURL + let workspace = editorState.workspaceURL + chatTooltipTask = Task { [weak self] in + try await Task.sleep(nanoseconds: 1_000_000_000) + try Task.checkCancellation() + guard + let self, + self.editorState?.fileURL == file, + self.editorState?.workspaceURL == workspace, + completion == nil + else { return } + showChatTooltip = true + } + } + private func enable() { isEnabled = true escapeKeyHandler?.start() - xcodeObservation = xcodeObserver.statePublisher.sink { @Sendable state in - Task { @MainActor [weak self] in - await self?.handleXcodeStateChange(state) - } - } } private func handleXcodeStateChange(_ state: AXState) async { diff --git a/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionWindow.swift b/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionWindow.swift index e587fd283..2600acbf1 100644 --- a/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionWindow.swift +++ b/app/modules/features/CodeCompletionFeature/Sources/CodeCompletionWindow.swift @@ -79,7 +79,9 @@ final class CodeCompletionWindow: XcodeWindow { return nil } if viewModel.completion != nil, let completionTask = viewModel.completionTask { - updateViewModel(editor: editor, editorFrame: editorFrame, scrollViewFrame: scrollViewFrame, completionTask: completionTask) + updateViewModel(editor: editor, editorFrame: editorFrame, completionTask: completionTask) + } else if viewModel.showChatTooltip, let cursorLine = viewModel.lineThatNeedsVerticalOffset { + updateViewModelForChatTooltip(editor: editor, editorFrame: editorFrame, cursorLine: cursorLine) } else { completionId = nil completionRange = nil @@ -98,34 +100,62 @@ final class CodeCompletionWindow: XcodeWindow { private var hostingView: NSView? + /// Updates the view model with position info for the chat tooltip. + private func updateViewModelForChatTooltip( + editor: XcodeEditorState, + editorFrame: CGRect, + cursorLine: Int) + { + let position = CursorPosition(line: cursorLine, character: 0) + let selection = CursorRange(start: position, end: position) + guard + let content = xcodeObserver.state.focusedWorkspace?.tabs.first(where: { $0.isFocused })?.lastKnownContent, + let cursorRange = content.nsRange(of: selection) + else { + return + } + updateViewModel( + editor: editor, editorFrame: editorFrame, selection: selection, selectionRange: cursorRange, content: content) + } + + /// Updates the view model with position info for the given completion task. private func updateViewModel( editor: XcodeEditorState, editorFrame: CGRect, - scrollViewFrame _: CGRect, completionTask: CompletionTask) { + let content = completionTask.request.content + let selection = completionTask.request.selection if completionTask.id != completionId || completionRange == nil { completionId = completionTask.id // Cache `completionRange` as this requires counting characters throughout the completed file // which is somewhat resource intensive. - completionRange = completionTask.request.content.nsRange(of: completionTask.request.selection) - } - guard - let completionRange, - let completedTextFrame = editor.axElement.getTextFrame(range: completionRange)?.invertedFrame - else { - return + completionRange = content.nsRange(of: selection) } - let request = completionTask.request - let lineHeight = completedTextFrame.height / CGFloat(request.selection.end.line - request.selection.start.line + 1) + guard let completionRange else { return } + updateViewModel( + editor: editor, editorFrame: editorFrame, selection: selection, selectionRange: completionRange, content: content) + } + + /// Updates the view model with position info for the given selection (either from completion or chat tooltip). + private func updateViewModel( + editor: XcodeEditorState, + editorFrame: CGRect, + selection: CursorRange, + selectionRange: NSRange, + content: String) + { + guard let completedTextFrame = editor.axElement.getTextFrame(range: selectionRange)?.invertedFrame + else { return } + let lineHeight = completedTextFrame.height / CGFloat(selection.end.line - selection.start.line + 1) // Leading offset between editor frame and text area frame if leadingEditorOffset == nil || viewModel.lineHeight != lineHeight, - let range = request.content.nsRange(of: + let range = content.nsRange(of: .init( - start: .init(line: request.selection.start.line, character: 0), - end: .init(line: request.selection.start.line, character: 0))), + start: .init(line: selection.start.line, character: 0), + end: .init(line: selection.start.line, character: 0))), let baseline = editor.axElement.getTextFrame(range: range)?.invertedFrame { let leadingOffset = baseline.minX - editorFrame.minX @@ -137,10 +167,10 @@ final class CodeCompletionWindow: XcodeWindow { // Trailing offset between editor frame and text area frame if trailingEditorOffset == nil || viewModel.lineHeight != lineHeight, - let range = request.content.nsRange(of: + let range = content.nsRange(of: .init( - start: .init(line: request.selection.start.line, character: 0), - end: .init(line: request.selection.start.line + 1, character: 0))), + start: .init(line: selection.start.line, character: 0), + end: .init(line: selection.start.line + 1, character: 0))), let baseline = editor.axElement.getTextFrame(range: range)?.invertedFrame { let trailingOffset = editorFrame.maxX - baseline.maxX @@ -152,7 +182,7 @@ final class CodeCompletionWindow: XcodeWindow { if viewModel.lineHeight != lineHeight, - let (content, size) = request.content.contentToInferFontSize(around: request.selection, in: editor.axElement) + let (content, size) = content.contentToInferFontSize(around: selection, in: editor.axElement) { viewModel.lineHeight = size.height viewModel.updateFont( diff --git a/app/modules/features/SettingsFeature/Sources/AboutSettingsView.swift b/app/modules/features/SettingsFeature/Sources/AboutSettingsView.swift index 87b5fb7e8..4ebde3ef1 100644 --- a/app/modules/features/SettingsFeature/Sources/AboutSettingsView.swift +++ b/app/modules/features/SettingsFeature/Sources/AboutSettingsView.swift @@ -64,6 +64,9 @@ struct AboutSettingsView: View { } InfoRow(label: "Build", value: Bundle.main.version) InfoRow(label: "Bundle ID", value: Bundle.main.bundleIdentifier ?? "Unknown") + #if DEBUG + InfoRow(label: "Type", value: "DEBUG") + #endif Divider() PermissionStatusRow( permission: .accessibility, diff --git a/app/modules/features/SettingsFeature/Sources/KeyboardShortcutsSettingsView.swift b/app/modules/features/SettingsFeature/Sources/KeyboardShortcutsSettingsView.swift index 5e5facb23..ebfa0fb94 100644 --- a/app/modules/features/SettingsFeature/Sources/KeyboardShortcutsSettingsView.swift +++ b/app/modules/features/SettingsFeature/Sources/KeyboardShortcutsSettingsView.swift @@ -67,14 +67,14 @@ struct KeyboardShortcutView: View { Text(title + ":") .padding(.trailing, 8) - KeyBindingInputView(keyboardShortcut: $keyboardShortcut, lineHeight: Constants.lineHeight) + KeyBindingInputView(keyboardShortcut: $keyboardShortcut, defaultValue: defaultValue, lineHeight: Constants.lineHeight) Spacer(minLength: 0) - if keyboardShortcut != nil, keyboardShortcut != defaultValue { + if let keyboardShortcut, keyboardShortcut != defaultValue { HoveredButton( action: { - keyboardShortcut = nil + self.keyboardShortcut = nil }, onHoverColor: colorScheme.tertiarySystemBackground, backgroundColor: colorScheme.secondarySystemBackground, @@ -111,24 +111,14 @@ struct KeyboardShortcutView: View { } -extension KeyboardShortcut { - /// A string representation of the key binding. - var display: String { - ( - modifiers - .map(\.description) - + [key.description]) - .joined(separator: " ") - } -} - // MARK: - KeyBindingInputView struct KeyBindingInputView: View { - init(keyboardShortcut: Binding, lineHeight: CGFloat = 20) { + init(keyboardShortcut: Binding, defaultValue: KeyboardShortcut, lineHeight: CGFloat = 20) { _keyboardShortcut = keyboardShortcut + self.defaultValue = defaultValue self.lineHeight = lineHeight - _inputShortcut = .init(initialValue: NSAttributedString(string: keyboardShortcut.wrappedValue?.display ?? "")) + _inputShortcut = .init(initialValue: NSAttributedString(string: (keyboardShortcut.wrappedValue ?? defaultValue).display)) } var body: some View { @@ -140,7 +130,8 @@ struct KeyBindingInputView: View { inputShortcut = .init(string: shortcut.display) keyboardShortcut = shortcut } else if key == .delete { - inputShortcut = NSAttributedString(string: "") + // Reset to default (nil means use default) + inputShortcut = NSAttributedString(string: defaultValue.display) keyboardShortcut = nil } return true @@ -152,7 +143,7 @@ struct KeyBindingInputView: View { borderColor: colorScheme.textAreaBorderColor, borderWidth: 0.5) .onChange(of: keyboardShortcut) { newValue in - inputShortcut = NSAttributedString(string: newValue?.display ?? "") + inputShortcut = NSAttributedString(string: (newValue ?? defaultValue).display) } } @@ -160,6 +151,7 @@ struct KeyBindingInputView: View { @Binding private var keyboardShortcut: KeyboardShortcut? @Environment(\.colorScheme) private var colorScheme + private let defaultValue: KeyboardShortcut private let lineHeight: CGFloat } diff --git a/app/modules/serviceInterfaces/SettingsServiceInterface/Sources/SettingsService.swift b/app/modules/serviceInterfaces/SettingsServiceInterface/Sources/SettingsService.swift index f0a1f83c0..69d1c6695 100644 --- a/app/modules/serviceInterfaces/SettingsServiceInterface/Sources/SettingsService.swift +++ b/app/modules/serviceInterfaces/SettingsServiceInterface/Sources/SettingsService.swift @@ -347,6 +347,11 @@ extension Settings { self.key = key self.modifiers = modifiers } + + /// A string representation of the keyboard shortcut (e.g. "⌘ L"). + public var display: String { + (modifiers.map(\.description) + [key.description]).joined(separator: " ") + } } public enum KeyboardShortcutKey: String, Codable, Sendable, CaseIterable, CodingKeyRepresentable { @@ -359,8 +364,8 @@ extension Settings { public var defaultShortcut: KeyboardShortcut { switch self { - case .addContextToCurrentChat: KeyboardShortcut(key: "i", modifiers: [.command]) - case .addContextToNewChat: KeyboardShortcut(key: "i", modifiers: [.command, .shift]) + case .addContextToCurrentChat: KeyboardShortcut(key: "l", modifiers: [.command]) + case .addContextToNewChat: KeyboardShortcut(key: "l", modifiers: [.command, .shift]) case .dismissChat: KeyboardShortcut(key: .escape, modifiers: [.command]) } } diff --git a/app/modules/services/XcodeObserverService/Sources/SourceEditorObserver.swift b/app/modules/services/XcodeObserverService/Sources/SourceEditorObserver.swift index 4e7394d3f..4486b9834 100644 --- a/app/modules/services/XcodeObserverService/Sources/SourceEditorObserver.swift +++ b/app/modules/services/XcodeObserverService/Sources/SourceEditorObserver.swift @@ -79,7 +79,6 @@ final class SourceEditorObserver: AXElementObserver, @unchecked Sendable { axSubscription = axNotificationPublisher.sink { [weak self] notification in guard let self else { return } - print("Got axnotification at \(Date().timeIntervalSince1970)") guard let event = AXNotification(rawValue: notification.name) else { return