301 lines
14 KiB
Swift
301 lines
14 KiB
Swift
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 Sybil’s 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)
|
||
}
|
||
}
|