Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions app/modules/App/Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -73,6 +95,9 @@ struct CodeCompletionView: View {
.clipped()
}
}

@Environment(\.colorScheme) private var colorScheme

}

// MARK: - CompletionDiffView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<Void, Error>?
private(set) var styledCompletion: SyntaxHighlightedCompletion?
Expand All @@ -125,18 +133,39 @@ 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
keyEventState.update(isAutomaticCompletionEnabled: isAutomaticCompletionEnabled)
}
}

/// 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 showing having a jaggy scrolling
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phrase "instead of showing having a jaggy scrolling" is grammatically incorrect. It should be "instead of having jaggy scrolling" or "instead of showing a jaggy scrolling effect".

Suggested change
// When changed, hide the tooltip instead of showing having a jaggy scrolling
// When changed, hide the tooltip instead of having jaggy scrolling

Copilot uses AI. Check for mistakes.
chatTooltipTask?.cancel()
showChatTooltip = false
updateChatTooltipVisibility() // Reset a timer to show the tooltip
}
}
}
}
Expand All @@ -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()
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -299,10 +336,12 @@ final class CodeCompletionViewModel {
private var xcodeObservation: AnyCancellable?

private var statusMessageTask: Task<Void, Never>?
private var chatTooltipTask: Task<Void, Error>?

private var editorState: EditorState? {
didSet {
keyEventState.update(hasEditorState: editorState != nil)
updateChatTooltipVisibility()
}
}

Expand All @@ -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<XcodeState>) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ 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)

Expand Down Expand Up @@ -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<KeyboardShortcut?>, lineHeight: CGFloat = 20) {
init(keyboardShortcut: Binding<KeyboardShortcut?>, 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 {
Expand All @@ -152,14 +142,15 @@ 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)
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the delete key is pressed to clear the shortcut, the inputShortcut is first set to an empty string in the onKeyDown handler (line 133), but then when keyboardShortcut is set to nil (line 134), this onChange handler will immediately override it with the defaultValue.display (line 145). This means the user will see the default shortcut displayed instead of an empty field, which is confusing UX. The old code had the same behavior (it used an empty string), but now with the refactored code that always shows the default when nil, this creates an inconsistency. Consider checking if the new value is nil in the onChange handler and preserving the empty string in that case.

Suggested change
inputShortcut = NSAttributedString(string: (newValue ?? defaultValue).display)
if let shortcut = newValue {
inputShortcut = NSAttributedString(string: shortcut.display)
}

Copilot uses AI. Check for mistakes.
}
}

@State private var inputShortcut: NSAttributedString
@Binding private var keyboardShortcut: KeyboardShortcut?
@Environment(\.colorScheme) private var colorScheme

private let defaultValue: KeyboardShortcut
private let lineHeight: CGFloat

}
Loading
Loading