adds quick question UI for mac catalyst target

This commit is contained in:
2026-08-21 17:28:08 -07:00
parent eb2b0d3ca0
commit 5f9fc82b86
20 changed files with 2015 additions and 2 deletions
@@ -0,0 +1,229 @@
import AppKit
import Carbon
// AppKit is unavailable to Catalyst targets. This native bundle uses only
// public AppKit/Carbon APIs and communicates with the client through Foundation.
@MainActor
@objc(SybilMacQuickQuestionPlugin)
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
private var panel: QuickQuestionPanel?
private var panelView: QuickQuestionPanelView?
private var hotKey: EventHotKeyRef?
private var eventHandler: EventHandlerRef?
private var outsideClickMonitor: Any?
private var hotKeyState = SybilQuickQuestionHotKeyState()
private var isDismissing = false
private var shortcutAvailable = false
private var windowlessActivity: NSObjectProtocol?
private var latestState = SybilQuickQuestionPanelState()
private var previousApplication: NSRunningApplication?
required override init() {
super.init()
}
var isVisible: Bool { panel?.isVisible == true }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
self.delegate = delegate
if windowlessActivity == nil {
// Keep the hotkey service alive with no windows. This option does
// not prevent sleep/App Nap or intercept an explicit Quit command.
windowlessActivity = ProcessInfo.processInfo.beginActivity(
options: .automaticTerminationDisabled,
reason: "Quick Question global shortcut"
)
}
guard eventHandler == nil else { return noErr }
var eventTypes = [
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
]
let context = Unmanaged.passUnretained(self).toOpaque()
let handlerStatus = InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, context in
guard let event, let context else { return OSStatus(eventNotHandledErr) }
var identifier = EventHotKeyID()
let status = GetEventParameter(
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
)
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
return OSStatus(eventNotHandledErr)
}
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
// Application event handlers run on the main event loop.
MainActor.assumeIsolated {
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
plugin.delegate?.quickQuestionToggleRequested()
}
}
return noErr
},
eventTypes.count, &eventTypes, context, &eventHandler
)
guard handlerStatus == noErr else { return handlerStatus }
let status = RegisterEventHotKey(
UInt32(kVK_Space), UInt32(optionKey),
EventHotKeyID(signature: 0x53595151, id: 1),
GetApplicationEventTarget(), 0, &hotKey
)
shortcutAvailable = status == noErr
return status
}
func updateState(_ data: Data) {
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
// Updating a background request must not create a non-hosting window.
// The controller creates/orders the panel only after UIKit is foreground.
latestState = state
panelView?.update(state, shortcutAvailable: shortcutAvailable)
if isVisible { resizePanel() }
}
func show() {
createPanelIfNeeded()
guard let panel, let panelView else { return }
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
let width = min(680, visibleFrame.width - 48)
panel.setContentSize(NSSize(width: width, height: 240))
panelView.layoutSubtreeIfNeeded()
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
let y = max(visibleFrame.minY + 24, top - height)
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
// The controller has verified UIKit foreground readiness. A
// nonactivating panel can then take focus without raising a workspace.
panel.makeKeyAndOrderFront(nil)
panel.makeFirstResponder(panelView.promptView)
panelView.preparePromptForPresentation()
installOutsideClickMonitor()
}
func hide() {
dismissPanel(restoringPreviousApplication: true)
}
func activateForQuickQuestion() {
if let frontmost = NSWorkspace.shared.frontmostApplication,
frontmost != NSRunningApplication.current {
previousApplication = frontmost
}
// Do not order a window here. Catalyst must finish its own foreground
// transition before the controller calls show().
NSApp.activate()
}
func activateMainWindow() {
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
previousApplication = nil
NSApp.activate()
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
}
private func dismissPanel(restoringPreviousApplication: Bool) {
guard !isDismissing else { return }
let wasVisible = isVisible
isDismissing = true
if wasVisible { panel?.orderOut(nil) }
removeOutsideClickMonitor()
isDismissing = false
let applicationToRestore = previousApplication
previousApplication = nil
if restoringPreviousApplication, NSApp.isActive,
let applicationToRestore, !applicationToRestore.isTerminated {
NSApp.yieldActivation(to: applicationToRestore)
applicationToRestore.activate(options: [])
}
if wasVisible { delegate?.quickQuestionPanelDismissed() }
}
func stop() {
previousApplication = nil
hide()
removeOutsideClickMonitor()
if let hotKey { UnregisterEventHotKey(hotKey) }
if let eventHandler { RemoveEventHandler(eventHandler) }
hotKey = nil
eventHandler = nil
hotKeyState = SybilQuickQuestionHotKeyState()
if let windowlessActivity {
ProcessInfo.processInfo.endActivity(windowlessActivity)
}
windowlessActivity = nil
delegate = nil
}
func windowDidResignKey(_ notification: Notification) {
// Focus is already moving to a window/app the user chose.
dismissPanel(restoringPreviousApplication: false)
}
private func createPanelIfNeeded() {
guard panel == nil else { return }
let panel = QuickQuestionPanel(
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
)
panel.title = "Quick Question"
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
panel.becomesKeyOnlyIfNeeded = false
panel.worksWhenModal = true
panel.isReleasedWhenClosed = false
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.isMovableByWindowBackground = true
panel.appearance = NSAppearance(named: .darkAqua)
panel.delegate = self
panel.onDismiss = { [weak self] in self?.hide() }
let view = QuickQuestionPanelView()
view.delegate = delegate
view.onDismiss = { [weak self] in self?.hide() }
panel.contentView = view
panelView = view
self.panel = panel
}
private func resizePanel() {
guard let panel, let panelView else { return }
panelView.layoutSubtreeIfNeeded()
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
guard abs(height - panel.frame.height) > 1 else { return }
var frame = panel.frame
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
frame.size.height = height
panel.setFrame(frame, display: true)
}
private func installOutsideClickMonitor() {
guard outsideClickMonitor == nil else { return }
// Mouse-only monitoring does not require Accessibility or Input
// Monitoring permission. No global keyboard event tap is installed.
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
Task { @MainActor [weak self] in self?.hide() }
}
}
private func removeOutsideClickMonitor() {
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
outsideClickMonitor = nil
}
}
@MainActor
private final class QuickQuestionPanel: NSPanel {
var onDismiss: (() -> Void)?
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
override func cancelOperation(_ sender: Any?) { onDismiss?() }
}
@@ -0,0 +1,300 @@
import AppKit
@MainActor
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
var onDismiss: (() -> Void)?
let promptView = QuickQuestionPromptView()
private let stack = NSStackView()
private let answerView = NSTextView()
private let answerScroll = NSScrollView()
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let providerPopup = NSPopUpButton()
private let modelPopup = NSPopUpButton()
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
private let sendButton = NSButton()
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
private var panelState = SybilQuickQuestionPanelState()
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
material = .hudWindow
blendingMode = .behindWindow
state = .active
wantsLayer = true
layer?.cornerRadius = 18
layer?.masksToBounds = true
layer?.borderWidth = 1
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.detachesHiddenViews = true
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
])
addRow(makeHeader())
configureTextView(promptView, fontSize: 22)
promptView.delegate = self
promptView.isRichText = false
promptView.allowsUndo = true
promptView.setAccessibilityLabel("Quick question prompt")
promptView.onSubmit = { [weak self] in self?.submit() }
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
let promptScroll = makeScrollView(documentView: promptView)
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
addRow(promptScroll)
configureTextView(answerView, fontSize: 15)
answerView.isEditable = false
answerView.isSelectable = true
answerView.setAccessibilityLabel("Quick question answer")
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
answerScroll.drawsBackground = false
answerScroll.hasVerticalScroller = true
answerScroll.autohidesScrollers = true
answerScroll.documentView = answerView
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
addRow(answerScroll)
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = .secondaryLabelColor
statusLabel.maximumNumberOfLines = 3
addRow(statusLabel)
addRow(makeControls())
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
hint.font = .systemFont(ofSize: 11)
hint.textColor = .secondaryLabelColor
addRow(hint)
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
let previous = panelState
panelState = next
if promptView.string != next.prompt, !promptView.hasMarkedText() {
promptView.string = next.prompt
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
}
promptView.isEditable = !next.isBusy
promptView.needsDisplay = true
if previous.answer != next.answer {
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
}
answerScroll.isHidden = next.answer.isEmpty
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
providerPopup.removeAllItems()
for provider in next.providers {
providerPopup.addItem(withTitle: provider.title)
providerPopup.lastItem?.representedObject = provider.id
}
}
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
providerPopup.selectItem(at: index)
}
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
modelPopup.removeAllItems()
var models = next.models
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
modelPopup.addItems(withTitles: models)
modelPopup.selectItem(withTitle: next.model)
}
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
convertButton.isEnabled = next.canConvert
sendButton.isEnabled = next.canSend
openAppButton.isHidden = next.isAuthenticated
convertButton.isHidden = !next.isAuthenticated
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybils File menu."
let status: String
if let error = next.error {
status = error
statusLabel.textColor = .systemRed
} else {
statusLabel.textColor = .secondaryLabelColor
if next.isCheckingSession {
status = "Connecting to Sybil…"
} else if !next.isAuthenticated {
status = "Connect in Sybil before asking a question."
} else if next.isConverting {
status = "Opening in chat…"
} else if next.isSending {
status = next.toolSummaries.last ?? "Thinking…"
} else {
status = ""
}
}
statusLabel.stringValue = status
statusLabel.isHidden = status.isEmpty
}
func preparePromptForPresentation() {
// Select only when invoked, never on streamed updates or normal edits.
promptView.selectAll(nil)
}
func textDidChange(_ notification: Notification) {
guard !panelState.isBusy else { return }
promptView.needsDisplay = true
delegate?.quickQuestionPromptChanged(promptView.string)
}
private func addRow(_ view: NSView) {
stack.addArrangedSubview(view)
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
private func makeHeader() -> NSView {
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
icon.contentTintColor = .systemPurple
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
let title = NSTextField(labelWithString: "Quick Question")
title.font = .systemFont(ofSize: 14, weight: .semibold)
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
shortcutLabel.textColor = .secondaryLabelColor
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
close.isBordered = false
close.toolTip = "Close (Esc)"
close.setAccessibilityLabel("Close Quick Question")
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
row.spacing = 8
row.alignment = .centerY
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
return row
}
private func makeControls() -> NSView {
providerPopup.target = self
providerPopup.action = #selector(changeProvider)
providerPopup.setAccessibilityLabel("Quick question provider")
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
modelPopup.target = self
modelPopup.action = #selector(changeModel)
modelPopup.setAccessibilityLabel("Quick question model")
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
convertButton.target = self
convertButton.action = #selector(convertToChat)
convertButton.bezelStyle = .rounded
openAppButton.target = self
openAppButton.action = #selector(openApp)
openAppButton.bezelStyle = .rounded
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
sendButton.imagePosition = .imageOnly
sendButton.bezelStyle = .circular
sendButton.contentTintColor = .systemPurple
sendButton.target = self
sendButton.action = #selector(submit)
sendButton.setAccessibilityLabel("Ask Quick Question")
sendButton.toolTip = "Ask (Return)"
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
row.spacing = 8
row.alignment = .centerY
row.detachesHiddenViews = true
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
return row
}
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
view.drawsBackground = false
view.textColor = .labelColor
view.insertionPointColor = .labelColor
view.font = .systemFont(ofSize: fontSize)
view.textContainerInset = NSSize(width: 0, height: 4)
view.isHorizontallyResizable = false
view.isVerticallyResizable = true
view.autoresizingMask = [.width]
view.textContainer?.widthTracksTextView = true
view.textContainer?.lineFragmentPadding = 0
}
private func makeScrollView(documentView: NSView) -> NSScrollView {
let scroll = NSScrollView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.documentView = documentView
return scroll
}
private static func renderAnswer(_ text: String) -> NSAttributedString {
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
for run in parsed.runs {
let intent = run.inlinePresentationIntent ?? []
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
}
return rendered
}
@objc private func closePanel() { onDismiss?() }
@objc private func changeProvider() {
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
delegate?.quickQuestionProviderChanged(provider)
}
@objc private func changeModel() {
guard let model = modelPopup.titleOfSelectedItem else { return }
delegate?.quickQuestionModelChanged(model)
}
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
@objc private func submit() {
guard panelState.canSend, !promptView.hasMarkedText() else { return }
delegate?.quickQuestionSubmitRequested()
}
}
@MainActor
final class QuickQuestionPromptView: NSTextView {
var onSubmit: (() -> Void)?
var onToggle: (() -> Void)?
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty else { return }
("Ask anything…" as NSString).draw(
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
)
}
override func keyDown(with event: NSEvent) {
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 49, modifiers == .option {
if !event.isARepeat { onToggle?() }
return
}
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
if !event.isARepeat { onSubmit?() }
return
}
// Let the text input system handle Shift+Return and IME confirmation.
super.keyDown(with: event)
}
}
@@ -0,0 +1,25 @@
targets:
SybilMacQuickQuestion:
type: bundle
platform: macOS
deploymentTarget: "15.0"
sources:
- Sources
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
dependencies:
- sdk: AppKit.framework
- sdk: Carbon.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
PRODUCT_NAME: SybilMacQuickQuestion
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
SUPPORTS_MACCATALYST: NO
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
MACH_O_TYPE: mh_bundle
SKIP_INSTALL: YES