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) } }