adds quick question UI for mac catalyst target
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -8,7 +8,7 @@ struct SybilApp: App
|
||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
||||
SplitView()
|
||||
}
|
||||
.commands {
|
||||
@@ -24,9 +24,18 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
@@ -56,11 +65,22 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if scene is UIWindowScene, session.role == .windowApplication {
|
||||
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
||||
}
|
||||
#endif
|
||||
|
||||
func windowScene(
|
||||
_ windowScene: UIWindowScene,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
|
||||
@@ -11,6 +11,13 @@ targets:
|
||||
dependencies:
|
||||
- package: Sybil
|
||||
product: Sybil
|
||||
- target: SybilMacQuickQuestion
|
||||
destinationFilters: [macCatalyst]
|
||||
embed: true
|
||||
link: false
|
||||
codeSign: true
|
||||
copy:
|
||||
destination: plugins
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||
@@ -29,6 +36,7 @@ targets:
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
|
||||
@@ -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 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -78,6 +78,7 @@ public struct SplitView: View {
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||
SybilQuickQuestionView(
|
||||
viewModel: viewModel,
|
||||
@@ -85,7 +86,11 @@ public struct SplitView: View {
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
#endif
|
||||
.task {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
await viewModel.bootstrap()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
@@ -107,6 +112,9 @@ public struct SplitView: View {
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
viewModel.markAppActiveForNetwork()
|
||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||
return
|
||||
@@ -151,8 +159,13 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
SybilMacQuickQuestionController.shared.show()
|
||||
#else
|
||||
quickQuestionFocusRequest += 1
|
||||
isQuickQuestionPresented = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleQuickQuestionDismissed() {
|
||||
@@ -161,23 +174,57 @@ public struct SplitView: View {
|
||||
}
|
||||
|
||||
public struct SybilCommands: Commands {
|
||||
public static let workspaceWindowID = "sybil-workspace"
|
||||
|
||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
windowCommands.newChatOrWindow(
|
||||
newChat: keyboardActions?.newChat,
|
||||
openWindow: openNewWindow
|
||||
)
|
||||
#else
|
||||
keyboardActions?.newChat()
|
||||
#endif
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||
#else
|
||||
.disabled(keyboardActions == nil)
|
||||
#endif
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Button("New Window", action: openNewWindow)
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
|
||||
Button("New Search") {
|
||||
keyboardActions?.newSearch()
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||
#else
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
#endif
|
||||
.disabled(keyboardActions == nil)
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Divider()
|
||||
Button("Quick Question") {
|
||||
SybilMacQuickQuestionController.shared.toggle()
|
||||
}
|
||||
.keyboardShortcut(.space, modifiers: .option)
|
||||
#endif
|
||||
}
|
||||
|
||||
CommandMenu("Conversation") {
|
||||
@@ -194,6 +241,13 @@ public struct SybilCommands: Commands {
|
||||
.disabled(keyboardActions == nil)
|
||||
}
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private func openNewWindow() {
|
||||
// Targeting a WindowGroup without a value creates a fresh scene every
|
||||
// time, even when another workspace window is already open.
|
||||
openWindow(id: Self.workspaceWindowID)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct SybilKeyboardActions {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
// This Foundation-only contract is compiled into both the Catalyst client and the
|
||||
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelBridging)
|
||||
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
|
||||
init()
|
||||
var isVisible: Bool { get }
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
|
||||
func updateState(_ data: Data)
|
||||
func show()
|
||||
func hide()
|
||||
func activateForQuickQuestion()
|
||||
func activateMainWindow()
|
||||
func stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(SybilQuickQuestionPanelDelegate)
|
||||
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
|
||||
func quickQuestionToggleRequested()
|
||||
func quickQuestionPromptChanged(_ prompt: String)
|
||||
func quickQuestionSubmitRequested()
|
||||
func quickQuestionProviderChanged(_ provider: String)
|
||||
func quickQuestionModelChanged(_ model: String)
|
||||
func quickQuestionConvertRequested()
|
||||
func quickQuestionOpenAppRequested()
|
||||
func quickQuestionPanelDismissed()
|
||||
}
|
||||
|
||||
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
|
||||
struct ProviderOption: Codable, Equatable, Sendable {
|
||||
var id: String
|
||||
var title: String
|
||||
}
|
||||
|
||||
var prompt = ""
|
||||
var answer = ""
|
||||
var toolSummaries: [String] = []
|
||||
var provider = ""
|
||||
var providers: [ProviderOption] = []
|
||||
var model = ""
|
||||
var models: [String] = []
|
||||
var isSending = false
|
||||
var isConverting = false
|
||||
var canSend = false
|
||||
var canConvert = false
|
||||
var isAuthenticated = false
|
||||
var isCheckingSession = true
|
||||
var error: String?
|
||||
|
||||
var isBusy: Bool { isSending || isConverting }
|
||||
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
|
||||
}
|
||||
|
||||
// Carbon can deliver multiple presses while a key is held. Consume one toggle
|
||||
// per press/release pair, without suppressing a second deliberate press.
|
||||
struct SybilQuickQuestionHotKeyState {
|
||||
private var isPressed = false
|
||||
|
||||
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
|
||||
if !isKeyDown {
|
||||
isPressed = false
|
||||
return false
|
||||
}
|
||||
guard !isPressed else { return false }
|
||||
isPressed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
|
||||
public static let shared = SybilMacQuickQuestionController()
|
||||
|
||||
private var pluginBundle: Bundle?
|
||||
private var bridge: (any SybilQuickQuestionPanelBridging)?
|
||||
private var viewModel: SybilViewModel?
|
||||
private var observationGeneration = 0
|
||||
private var isPresentationRequested = false
|
||||
private var activationRequested = false
|
||||
private var presentationTask: Task<Void, Never>?
|
||||
private var foregroundObservation: AnyCancellable?
|
||||
private var notificationCenter = NotificationCenter.default
|
||||
private var isApplicationInForeground: @MainActor () -> Bool = {
|
||||
UIApplication.shared.applicationState != .background
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// Inject foreground readiness as well as the panel so tests can exercise
|
||||
// windowless activation without activating apps or showing real windows.
|
||||
init(
|
||||
bridge: any SybilQuickQuestionPanelBridging,
|
||||
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
|
||||
notificationCenter: NotificationCenter = NotificationCenter()
|
||||
) {
|
||||
self.bridge = bridge
|
||||
self.isApplicationInForeground = isApplicationInForeground
|
||||
self.notificationCenter = notificationCenter
|
||||
super.init()
|
||||
_ = bridge.start(delegate: self)
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
}
|
||||
|
||||
public func start() {
|
||||
observeForegroundTransitionsIfNeeded()
|
||||
guard bridge == nil else { return }
|
||||
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
|
||||
let bundle = Bundle(url: url)
|
||||
else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try bundle.loadAndReturnError()
|
||||
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
|
||||
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
|
||||
return
|
||||
}
|
||||
let plugin = pluginClass.init()
|
||||
pluginBundle = bundle
|
||||
bridge = plugin
|
||||
let status = plugin.start(delegate: self)
|
||||
if status != 0 {
|
||||
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
|
||||
}
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ viewModel: SybilViewModel) {
|
||||
guard self.viewModel !== viewModel else { return }
|
||||
// A window becoming active must not replace an in-flight quick question.
|
||||
if self.viewModel != nil,
|
||||
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
|
||||
return
|
||||
}
|
||||
self.viewModel = viewModel
|
||||
if bridge?.isVisible == true { updateAndObserve() }
|
||||
}
|
||||
|
||||
public func toggle() {
|
||||
start()
|
||||
if isPresentationRequested || bridge?.isVisible == true {
|
||||
cancelPresentation()
|
||||
bridge?.hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
public func show() {
|
||||
start()
|
||||
isPresentationRequested = true
|
||||
schedulePresentationIfNeeded()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
cancelPresentation()
|
||||
foregroundObservation = nil
|
||||
viewModel?.cancelQuickQuestion()
|
||||
bridge?.stop()
|
||||
bridge = nil
|
||||
// Keep the loaded bundle alive for the lifetime of its code.
|
||||
}
|
||||
|
||||
private func observeForegroundTransitionsIfNeeded() {
|
||||
guard foregroundObservation == nil else { return }
|
||||
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.schedulePresentationIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schedulePresentationIfNeeded() {
|
||||
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
|
||||
// Leave the Carbon/NSApplication event callback before touching windows.
|
||||
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
|
||||
// is not a substitute for checking UIKit's foreground lifecycle state.
|
||||
presentationTask = Task { @MainActor [weak self] in
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.presentationTask = nil
|
||||
guard self.isPresentationRequested else { return }
|
||||
guard self.isApplicationInForeground() else {
|
||||
guard !self.activationRequested else { return }
|
||||
self.activationRequested = true
|
||||
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
|
||||
self.bridge?.activateForQuickQuestion()
|
||||
// Also handle an activation that completes synchronously. If it
|
||||
// has not completed, subsequent UIKit notifications resume us.
|
||||
self.schedulePresentationIfNeeded()
|
||||
return
|
||||
}
|
||||
self.activationRequested = false
|
||||
self.updateAndObserve()
|
||||
self.bridge?.show()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPresentation() {
|
||||
isPresentationRequested = false
|
||||
activationRequested = false
|
||||
presentationTask?.cancel()
|
||||
presentationTask = nil
|
||||
observationGeneration += 1
|
||||
}
|
||||
|
||||
private func updateAndObserve() {
|
||||
guard let bridge else { return }
|
||||
observationGeneration += 1
|
||||
let generation = observationGeneration
|
||||
let state = withObservationTracking {
|
||||
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
do {
|
||||
bridge.updateState(try JSONEncoder().encode(state))
|
||||
} catch {
|
||||
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionToggleRequested() {
|
||||
toggle()
|
||||
}
|
||||
|
||||
func quickQuestionPromptChanged(_ prompt: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.updateQuickQuestionPrompt(prompt)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionSubmitRequested() {
|
||||
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
|
||||
viewModel.sendQuickQuestion()
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionProviderChanged(_ provider: String) {
|
||||
guard let viewModel, let provider = Provider(rawValue: provider),
|
||||
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionProvider(provider)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionModelChanged(_ model: String) {
|
||||
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||
viewModel.setQuickQuestionModel(model)
|
||||
updateAndObserve()
|
||||
}
|
||||
|
||||
func quickQuestionConvertRequested() {
|
||||
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await viewModel.convertQuickQuestionToChat() {
|
||||
self.cancelPresentation()
|
||||
self.openMainWindow()
|
||||
self.bridge?.hide()
|
||||
} else {
|
||||
self.updateAndObserve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quickQuestionOpenAppRequested() {
|
||||
cancelPresentation()
|
||||
openMainWindow()
|
||||
bridge?.hide()
|
||||
}
|
||||
|
||||
private func openMainWindow() {
|
||||
let session = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.session
|
||||
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
|
||||
bridge?.activateMainWindow()
|
||||
}
|
||||
|
||||
func quickQuestionPanelDismissed() {
|
||||
// Toggling out preserves the draft and lets an answer finish. Reopening
|
||||
// reads the latest state, without starting a new request.
|
||||
cancelPresentation()
|
||||
}
|
||||
}
|
||||
|
||||
extension SybilViewModel {
|
||||
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
|
||||
SybilQuickQuestionPanelState(
|
||||
prompt: quickQuestionPrompt,
|
||||
answer: quickQuestionAnswerText,
|
||||
toolSummaries: quickQuestionMessages.compactMap { message in
|
||||
guard let metadata = message.toolCallMetadata else { return nil }
|
||||
return metadata.summary ?? message.content
|
||||
},
|
||||
provider: quickQuestionProvider.rawValue,
|
||||
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
|
||||
model: quickQuestionModel,
|
||||
models: quickQuestionProviderModelOptions,
|
||||
isSending: isQuickQuestionSending,
|
||||
isConverting: isConvertingQuickQuestion,
|
||||
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
|
||||
canConvert: canConvertQuickQuestion,
|
||||
isAuthenticated: isAuthenticated,
|
||||
isCheckingSession: isCheckingSession,
|
||||
error: quickQuestionError
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Combine
|
||||
|
||||
// Scene lifetime is independent of focused commands: a connected window may
|
||||
// still be authenticating, inactive, or minimized. The native Quick Question
|
||||
// panel is not a workspace scene and does not count as an open window here.
|
||||
@MainActor
|
||||
public final class SybilMacWindowCommands: ObservableObject {
|
||||
public static let shared = SybilMacWindowCommands()
|
||||
|
||||
@Published private var connectedWindowIDs: Set<String> = []
|
||||
|
||||
init() {}
|
||||
|
||||
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
|
||||
|
||||
public func windowConnected(sessionID: String) {
|
||||
connectedWindowIDs.insert(sessionID)
|
||||
}
|
||||
|
||||
public func windowDisconnected(sessionID: String) {
|
||||
connectedWindowIDs.remove(sessionID)
|
||||
}
|
||||
|
||||
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
|
||||
!hasOpenWindows || hasFocusedActions
|
||||
}
|
||||
|
||||
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
|
||||
if hasOpenWindows {
|
||||
newChat?()
|
||||
} else {
|
||||
openWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var promptFocused: Bool
|
||||
@State private var promptSelection: TextSelection?
|
||||
|
||||
private var hasAnswerContent: Bool {
|
||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
|
||||
return
|
||||
}
|
||||
promptFocused = true
|
||||
let prompt = viewModel.quickQuestionPrompt
|
||||
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +97,7 @@ struct SybilQuickQuestionView: View {
|
||||
get: { viewModel.quickQuestionPrompt },
|
||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||
),
|
||||
selection: $promptSelection,
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($promptFocused)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
// A vertical TextField treats Return as editing, not form submission. Handle
|
||||
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
|
||||
// keys to the native text field. iOS keeps its software-keyboard behavior.
|
||||
@MainActor
|
||||
enum SybilReturnKeySubmission {
|
||||
static func handle(
|
||||
phase: KeyPress.Phases,
|
||||
modifiers: EventModifiers,
|
||||
submit: () -> Void
|
||||
) -> KeyPress.Result {
|
||||
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
|
||||
return .ignored
|
||||
}
|
||||
if phase == .down {
|
||||
submit()
|
||||
}
|
||||
// Consume autorepeat without submitting or inserting a newline.
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
|
||||
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
|
||||
}
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -636,9 +636,12 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
cancelQuickQuestion()
|
||||
// Lock submission before the task starts, including repeated Return
|
||||
// events delivered in the same main-actor turn.
|
||||
isQuickQuestionSending = true
|
||||
let selectedProvider = quickQuestionProvider
|
||||
let task = Task { [weak self] in
|
||||
guard let self else {
|
||||
guard let self, !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||
|
||||
@@ -601,6 +601,7 @@ struct SybilWorkspaceView: View {
|
||||
.onSubmit {
|
||||
submitComposer()
|
||||
}
|
||||
.submitOnMacReturn(submitComposer)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
#endif
|
||||
@testable import Sybil
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
@@ -951,6 +955,76 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
await sendTask.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func composerReturnSubmitsOnceAndConsumesRepeats() {
|
||||
var submissions = 0
|
||||
let initial = SybilReturnKeySubmission.handle(phase: .down, modifiers: []) { submissions += 1 }
|
||||
let repeated = SybilReturnKeySubmission.handle(phase: .repeat, modifiers: []) { submissions += 1 }
|
||||
#expect(initial == .handled)
|
||||
#expect(repeated == .handled)
|
||||
#expect(submissions == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func composerModifiedReturnPreservesNativeEditing() {
|
||||
var submissions = 0
|
||||
for modifiers: EventModifiers in [.shift, .command, .control, .option, [.shift, .command]] {
|
||||
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: modifiers) { submissions += 1 }
|
||||
#expect(result == .ignored)
|
||||
}
|
||||
#expect(submissions == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func composerReturnStillSubmitsWithCapsLock() {
|
||||
var submissions = 0
|
||||
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: .capsLock) { submissions += 1 }
|
||||
#expect(result == .handled)
|
||||
#expect(submissions == 1)
|
||||
}
|
||||
|
||||
@Test func quickQuestionHotKeyTogglesOncePerPress() {
|
||||
var keyState = SybilQuickQuestionHotKeyState()
|
||||
let keyDownEvents = [true, true, true, false, true, false, false, true]
|
||||
let toggles = keyDownEvents.map { keyState.shouldToggle(isKeyDown: $0) }
|
||||
#expect(toggles == [true, false, false, false, true, false, false, true])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionRejectsDuplicateSubmissionBeforeTaskStarts() async throws {
|
||||
let client = MockSybilClient()
|
||||
await client.setCompletionStreamEvents([.done(CompletionStreamDone(text: "One answer."))])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.quickQuestionPrompt = "One question"
|
||||
|
||||
let first = viewModel.sendQuickQuestion()
|
||||
let duplicate = viewModel.sendQuickQuestion()
|
||||
#expect(first != nil)
|
||||
#expect(duplicate == nil)
|
||||
#expect(viewModel.isQuickQuestionSending)
|
||||
await first?.value
|
||||
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 1)
|
||||
#expect(viewModel.quickQuestionAnswerText == "One answer.")
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionCancelledBeforeTaskStartsDoesNotSend() async throws {
|
||||
let client = MockSybilClient()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.quickQuestionPrompt = "Do not send this"
|
||||
|
||||
let task = viewModel.sendQuickQuestion()
|
||||
viewModel.cancelQuickQuestion()
|
||||
await task?.value
|
||||
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||
let client = MockSybilClient()
|
||||
@@ -1220,3 +1294,365 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@MainActor
|
||||
@Test func macNewChatCommandOpensWindowWhenNoWorkspacesRemain() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
var chats = 0
|
||||
var windows = 0
|
||||
|
||||
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
||||
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
||||
// A focused value can briefly outlive a closing scene. It must not route
|
||||
// Command+N back into a closed workspace.
|
||||
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
||||
#expect(windows == 2)
|
||||
#expect(chats == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macNewChatCommandUsesExistingWorkspace() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
commands.windowConnected(sessionID: "first")
|
||||
var chats = 0
|
||||
var windows = 0
|
||||
|
||||
#expect(commands.canStartNewChat(hasFocusedActions: true))
|
||||
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
||||
#expect(chats == 1)
|
||||
#expect(windows == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macNewChatCommandDoesNotMistakeUnavailableActionsForNoWindows() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
commands.windowConnected(sessionID: "authenticating")
|
||||
var windows = 0
|
||||
|
||||
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
||||
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
||||
#expect(windows == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macWindowCommandsTrackLastWindowClosureAndReconnection() {
|
||||
let commands = SybilMacWindowCommands()
|
||||
commands.windowConnected(sessionID: "first")
|
||||
commands.windowConnected(sessionID: "first")
|
||||
commands.windowConnected(sessionID: "second")
|
||||
commands.windowDisconnected(sessionID: "first")
|
||||
#expect(commands.hasOpenWindows)
|
||||
commands.windowDisconnected(sessionID: "first")
|
||||
#expect(commands.hasOpenWindows)
|
||||
commands.windowDisconnected(sessionID: "second")
|
||||
#expect(!commands.hasOpenWindows)
|
||||
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
||||
commands.windowConnected(sessionID: "third")
|
||||
#expect(commands.hasOpenWindows)
|
||||
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockQuickQuestionPanel: NSObject, SybilQuickQuestionPanelBridging {
|
||||
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||
var isVisible = false
|
||||
var state = SybilQuickQuestionPanelState()
|
||||
var presentationCount = 0
|
||||
var activationCount = 0
|
||||
var stateUpdateCount = 0
|
||||
var events: [String] = []
|
||||
var onShow: (() -> Void)?
|
||||
|
||||
required override init() { super.init() }
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
||||
self.delegate = delegate
|
||||
return 0
|
||||
}
|
||||
func updateState(_ data: Data) {
|
||||
state = try! JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data)
|
||||
stateUpdateCount += 1
|
||||
events.append("update")
|
||||
}
|
||||
func show() {
|
||||
onShow?()
|
||||
isVisible = true
|
||||
presentationCount += 1
|
||||
events.append("show")
|
||||
}
|
||||
func hide() {
|
||||
isVisible = false
|
||||
delegate?.quickQuestionPanelDismissed()
|
||||
}
|
||||
func activateForQuickQuestion() {
|
||||
activationCount += 1
|
||||
events.append("activate")
|
||||
}
|
||||
func activateMainWindow() {}
|
||||
func stop() { hide() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForQuickQuestion(_ condition: @MainActor () -> Bool) async throws {
|
||||
for _ in 0..<100 {
|
||||
if condition() { return }
|
||||
try await Task.sleep(for: .milliseconds(2))
|
||||
}
|
||||
try #require(condition())
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockQuickQuestionLifecycle {
|
||||
var isForeground = false
|
||||
let notifications = NotificationCenter()
|
||||
|
||||
func notify(_ name: Notification.Name) {
|
||||
notifications.post(name: name, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionWaitsForUIKitForegroundBeforeOpeningPanel() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
||||
viewModel.quickQuestionPrompt = "Original prompt"
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.attach(viewModel)
|
||||
panel.onShow = { #expect(lifecycle.isForeground) }
|
||||
|
||||
controller.toggle()
|
||||
// Never create/order a window inline in the hotkey's event callback.
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.activationCount == 0)
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
#expect(!panel.isVisible)
|
||||
|
||||
// "Will enter" is not proof of readiness: UIKit can still be backgrounded.
|
||||
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(panel.activationCount == 1)
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
|
||||
viewModel.quickQuestionPrompt = "The latest prompt while waiting"
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.events == ["activate", "update", "show"])
|
||||
#expect(panel.state.prompt == "The latest prompt while waiting")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionSecondToggleCancelsPendingActivation() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
controller.toggle()
|
||||
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(!panel.isVisible)
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 1)
|
||||
#expect(panel.activationCount == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionRapidTogglesKeepOnlyLatestPresentation() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
lifecycle.isForeground = true
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.toggle()
|
||||
controller.toggle()
|
||||
controller.toggle()
|
||||
#expect(panel.presentationCount == 0)
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 1)
|
||||
#expect(panel.activationCount == 0)
|
||||
|
||||
// Lifecycle notifications must not present/select the prompt a second time.
|
||||
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(panel.presentationCount == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionStopCancelsPendingPresentation() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
controller.show()
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
controller.stop()
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
#expect(panel.presentationCount == 0)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionRechecksForegroundAfterLeavingHotKeyCallback() async throws {
|
||||
let lifecycle = MockQuickQuestionLifecycle()
|
||||
lifecycle.isForeground = true
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(
|
||||
bridge: panel,
|
||||
isApplicationInForeground: { lifecycle.isForeground },
|
||||
notificationCenter: lifecycle.notifications
|
||||
)
|
||||
defer { controller.stop() }
|
||||
controller.show()
|
||||
lifecycle.isForeground = false
|
||||
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||
#expect(!panel.isVisible)
|
||||
#expect(panel.stateUpdateCount == 0)
|
||||
lifecycle.isForeground = true
|
||||
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionTogglePreservesDraftAndUsesLatestAnswer() async throws {
|
||||
let client = MockSybilClient()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "Keep this draft"
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(viewModel)
|
||||
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.isVisible)
|
||||
#expect(panel.state.prompt == "Keep this draft")
|
||||
#expect(panel.state.canSend)
|
||||
controller.toggle()
|
||||
#expect(!panel.isVisible)
|
||||
|
||||
viewModel.quickQuestionMessages = [
|
||||
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "An answer arrived while hidden.", name: nil)
|
||||
]
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.isVisible)
|
||||
#expect(panel.presentationCount == 2)
|
||||
#expect(panel.state.prompt == "Keep this draft")
|
||||
#expect(panel.state.answer == "An answer arrived while hidden.")
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionObservesStreamingAndKeepsItsOwner() async throws {
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "Original window"
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(viewModel)
|
||||
controller.show()
|
||||
|
||||
let otherWindow = SybilViewModel(settings: testSettings(named: #function + "-other")) { _ in MockSybilClient() }
|
||||
otherWindow.quickQuestionPrompt = "Other window"
|
||||
controller.attach(otherWindow)
|
||||
viewModel.quickQuestionMessages = [
|
||||
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "Streaming update", name: nil)
|
||||
]
|
||||
for _ in 0..<30 {
|
||||
if panel.state.answer == "Streaming update" { break }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
#expect(panel.state.answer == "Streaming update")
|
||||
#expect(panel.state.prompt == "Original window")
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionRetainsStateAfterLastWorkspaceCloses() async throws {
|
||||
let client = MockSybilClient()
|
||||
var windowViewModel: SybilViewModel? = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
windowViewModel?.isAuthenticated = true
|
||||
windowViewModel?.isCheckingSession = false
|
||||
windowViewModel?.quickQuestionPrompt = "A question from the closed window"
|
||||
windowViewModel?.quickQuestionMessages = [
|
||||
Message(id: "temp-assistant-quick-windowless", createdAt: Date(), role: .assistant, content: "Keep this answer too.", name: nil)
|
||||
]
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(try #require(windowViewModel))
|
||||
|
||||
// Releasing the workspace must not release the global shortcut's model.
|
||||
windowViewModel = nil
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.isVisible)
|
||||
#expect(panel.state.prompt == "A question from the closed window")
|
||||
#expect(panel.state.answer == "Keep this answer too.")
|
||||
controller.toggle()
|
||||
#expect(!panel.isVisible)
|
||||
controller.toggle()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
#expect(panel.presentationCount == 2)
|
||||
#expect(panel.state.canSend)
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func macQuickQuestionCannotSubmitBeforeAuthentication() async throws {
|
||||
let client = MockSybilClient()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = false
|
||||
viewModel.isCheckingSession = false
|
||||
let panel = MockQuickQuestionPanel()
|
||||
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||
controller.attach(viewModel)
|
||||
controller.show()
|
||||
try await waitForQuickQuestion { panel.isVisible }
|
||||
controller.quickQuestionPromptChanged("A signed-out question")
|
||||
controller.quickQuestionSubmitRequested()
|
||||
|
||||
#expect(!panel.state.canSend)
|
||||
#expect(panel.state.prompt == "A signed-out question")
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
let calls = await client.currentSnapshot()
|
||||
#expect(calls.runCompletionStream == 0)
|
||||
controller.stop()
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Sybil for iOS and Mac Catalyst
|
||||
|
||||
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
|
||||
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
|
||||
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
|
||||
release/archive signing settings are unchanged.
|
||||
|
||||
## Quick Question on macOS
|
||||
|
||||
While the Catalyst app is running, **Option+Space** toggles a floating Quick
|
||||
Question panel, including over other applications. The prompt is focused as soon
|
||||
as it opens, with any existing prompt selected so typing replaces it. The same
|
||||
select-on-open behavior applies to Quick Question on iPhone and iPad.
|
||||
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
|
||||
second **Option+Space**, the close button, or clicking outside dismisses it.
|
||||
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
||||
**Open in Chat** saves the question and answer in the regular workspace.
|
||||
|
||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
|
||||
|
||||
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
||||
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
||||
build opts out of automatic termination, and the native shortcut service holds a
|
||||
matching automatic-termination activity until it stops. No hidden keep-alive
|
||||
window or sleep-preventing activity is used. Mac scene support also allows the
|
||||
workspace to reopen after its last window is closed.
|
||||
|
||||
When Catalyst is backgrounded, opening Quick Question first requests app
|
||||
activation and waits for UIKit to enter the foreground before creating or ordering
|
||||
the native panel. Presentation is deferred out of the hotkey event callback.
|
||||
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
|
||||
that required activation returns focus to the previous app; explicitly opening
|
||||
the workspace keeps focus in Sybil.
|
||||
|
||||
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
|
||||
workspace window when none are open. **Command+Shift+N** always opens a new window,
|
||||
including while signed out. **Command+Option+N** starts a new search; iOS retains
|
||||
its existing Command+Shift+N search shortcut.
|
||||
|
||||
The shortcut is also shown under **File → Quick Question**. If another application
|
||||
already owns Option+Space, the panel reports that the shortcut is unavailable;
|
||||
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
|
||||
needed for the registered hotkey.
|
||||
|
||||
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
|
||||
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
|
||||
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
|
||||
provider/model preferences, streaming, and conversion to chat remain in the
|
||||
existing `SybilViewModel`; there is no separate backend or API contract. The panel
|
||||
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
|
||||
Catalyst process.
|
||||
|
||||
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
|
||||
action, and does not build or embed the macOS helper.
|
||||
|
||||
## Verification
|
||||
|
||||
- `just test`: existing Swift package tests on the iPhone simulator.
|
||||
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
|
||||
- For a manual Mac check, open the panel from another app, type immediately,
|
||||
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
|
||||
returns input to the previous app. Check a second display and a full-screen Space
|
||||
when available.
|
||||
- Close every Mac window, invoke Option+Space, and verify the saved question and
|
||||
answer remain available. Reopen the workspace from the panel, then explicitly
|
||||
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
||||
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
||||
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
|
||||
- Check Command+N both with a workspace open and after closing every workspace.
|
||||
Command+Shift+N must open another window even when one already exists, and
|
||||
Command+Option+N must start a search without opening a window.
|
||||
- With all workspace windows closed, switch to another app and repeatedly open
|
||||
and dismiss Quick Question, including two quick Option+Space presses while it
|
||||
is opening. Check that no late panel appears after cancellation and that no
|
||||
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
|
||||
@@ -24,6 +24,12 @@ run: generate
|
||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||
xcrun simctl launch booted net.buzzert.sybil2
|
||||
|
||||
build-mac: generate
|
||||
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
|
||||
|
||||
run-mac: build-mac
|
||||
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
|
||||
|
||||
beta:
|
||||
fastlane ios beta
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ packages:
|
||||
path: Packages/Sybil
|
||||
include:
|
||||
- Apps/Sybil/project.yml
|
||||
- Apps/SybilMacQuickQuestion/project.yml
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
|
||||
LOG_DIR="$ROOT/.devbox/logs"
|
||||
PID_DIR="$ROOT/.devbox/pids"
|
||||
|
||||
require_env() {
|
||||
local name="$1"
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "Missing required environment variable: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env OPENAI_API_KEY
|
||||
require_env EXA_API_KEY
|
||||
|
||||
mkdir -p "$LOG_DIR" "$PID_DIR"
|
||||
|
||||
export HOST="${HOST:-0.0.0.0}"
|
||||
export PORT="${PORT:-8787}"
|
||||
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
|
||||
|
||||
echo "Installing server dependencies..."
|
||||
npm ci --prefix "$ROOT/server" --no-audit --no-fund
|
||||
|
||||
echo "Installing web dependencies..."
|
||||
npm ci --prefix "$ROOT/web" --no-audit --no-fund
|
||||
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
|
||||
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
|
||||
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
|
||||
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
|
||||
tmux set-environment -t "$SESSION" HOST "$HOST"
|
||||
tmux set-environment -t "$SESSION" PORT "$PORT"
|
||||
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
|
||||
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
|
||||
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
|
||||
fi
|
||||
for name in \
|
||||
CHAT_CODEX_TOOL_ENABLED \
|
||||
CHAT_CODEX_REMOTE_HOST \
|
||||
CHAT_CODEX_REMOTE_USER \
|
||||
CHAT_CODEX_REMOTE_PORT \
|
||||
CHAT_CODEX_REMOTE_WORKDIR \
|
||||
CHAT_CODEX_SSH_KEY_PATH \
|
||||
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
|
||||
CHAT_CODEX_EXEC_TIMEOUT_MS \
|
||||
CHAT_SHELL_TOOL_ENABLED \
|
||||
CHAT_SHELL_EXEC_TIMEOUT_MS; do
|
||||
if [[ -n "${!name:-}" ]]; then
|
||||
tmux set-environment -t "$SESSION" "$name" "${!name}"
|
||||
fi
|
||||
done
|
||||
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
|
||||
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
|
||||
"npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
echo "Started Sybil in tmux session '$SESSION'."
|
||||
echo "Server: http://127.0.0.1:8787"
|
||||
echo "Web: http://127.0.0.1:5173"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "tmux not found; starting background processes with nohup."
|
||||
|
||||
if [[ -f "$PID_DIR/server.pid" ]]; then
|
||||
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -f "$PID_DIR/web.pid" ]]; then
|
||||
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$ROOT/server"
|
||||
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
|
||||
) >"$LOG_DIR/server.log" 2>&1 &
|
||||
echo "$!" >"$PID_DIR/server.pid"
|
||||
|
||||
(
|
||||
cd "$ROOT/web"
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5173
|
||||
) >"$LOG_DIR/web.log" 2>&1 &
|
||||
echo "$!" >"$PID_DIR/web.pid"
|
||||
|
||||
echo "Started Sybil background processes."
|
||||
echo "Server log: $LOG_DIR/server.log"
|
||||
echo "Web log: $LOG_DIR/web.log"
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO="${REPO:-Sybil-2}"
|
||||
WORKLOAD="${WORKLOAD:-sybil-dev}"
|
||||
CLUSTER="${CLUSTER:-viper}"
|
||||
NAMESPACE="${NAMESPACE:-buzzert}"
|
||||
QUOTA="${QUOTA:-research-api}"
|
||||
SECRET="${SECRET:-sybil-dev-env}"
|
||||
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
|
||||
INGRESS_PORT="${INGRESS_PORT:-5173}"
|
||||
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
|
||||
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
|
||||
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
|
||||
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
|
||||
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
|
||||
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
|
||||
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
|
||||
|
||||
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
|
||||
brix create \
|
||||
--project-root "$(dirname "$ROOT")" \
|
||||
--path "$REPO=$ROOT" \
|
||||
--repositories "$REPO" \
|
||||
--include-untracked \
|
||||
--reconcile-policy continuous \
|
||||
-- \
|
||||
"cluster=$CLUSTER" \
|
||||
"namespace=$NAMESPACE" \
|
||||
"name=$WORKLOAD" \
|
||||
"pool.spec.quota=$QUOTA" \
|
||||
"pool.spec.size.resource=cpu" \
|
||||
"pool.spec.size.quantity=1" \
|
||||
"pool.spec.priority=low"
|
||||
|
||||
if [[ "$REPO" != "openai" ]]; then
|
||||
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
|
||||
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
|
||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
|
||||
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
|
||||
fi
|
||||
|
||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
|
||||
--from-file=id_ed25519="$tmpdir/id_ed25519" \
|
||||
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
|
||||
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||
fi
|
||||
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
|
||||
--arg host "$CODEX_SERVICE" \
|
||||
--arg port "$CODEX_SSH_PORT" \
|
||||
--arg workdir "$CODEX_WORKDIR" \
|
||||
--arg keyPath "$CODEX_SSH_KEY_PATH" \
|
||||
'{
|
||||
stringData: {
|
||||
CHAT_CODEX_TOOL_ENABLED: "true",
|
||||
CHAT_CODEX_REMOTE_HOST: $host,
|
||||
CHAT_CODEX_REMOTE_USER: "root",
|
||||
CHAT_CODEX_REMOTE_PORT: $port,
|
||||
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
|
||||
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
|
||||
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
|
||||
CHAT_SHELL_TOOL_ENABLED: "true"
|
||||
}
|
||||
}')"
|
||||
|
||||
ensure_codex_deployment() {
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: $CODEX_SERVICE
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
tolerations:
|
||||
- key: openai.com/team
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: $CODEX_CONTAINER
|
||||
image: $CODEX_IMAGE
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/bin/bash", "-lc"]
|
||||
args:
|
||||
- |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if ! command -v sshd >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
npm install -g @openai/codex
|
||||
fi
|
||||
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
|
||||
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
|
||||
chmod 700 /root/.ssh
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
{
|
||||
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
|
||||
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
|
||||
fi
|
||||
if [[ -n "\${EXA_API_KEY:-}" ]]; then
|
||||
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
|
||||
fi
|
||||
} >/root/.sybil-codex-env
|
||||
chmod 600 /root/.sybil-codex-env
|
||||
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ -f /root/.sybil-codex-env ]]; then
|
||||
source /root/.sybil-codex-env
|
||||
fi
|
||||
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
|
||||
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
|
||||
fi
|
||||
exec /bin/bash -l
|
||||
SSH_COMMAND
|
||||
chmod 700 /usr/local/bin/sybil-codex-ssh-command
|
||||
ssh-keygen -A
|
||||
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
|
||||
Port $CODEX_SSH_PORT
|
||||
ListenAddress 0.0.0.0
|
||||
PermitRootLogin prohibit-password
|
||||
PasswordAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AcceptEnv OPENAI_API_KEY EXA_API_KEY
|
||||
AuthorizedKeysFile .ssh/authorized_keys
|
||||
PermitUserEnvironment no
|
||||
ForceCommand /usr/local/bin/sybil-codex-ssh-command
|
||||
AllowTcpForwarding no
|
||||
X11Forwarding no
|
||||
Subsystem sftp internal-sftp
|
||||
PidFile /run/sshd/sybil-codex.pid
|
||||
SSHD_CONFIG
|
||||
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
|
||||
ports:
|
||||
- name: ssh
|
||||
containerPort: $CODEX_SSH_PORT
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: $SECRET
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
volumeMounts:
|
||||
- name: sybil-codex-ssh
|
||||
mountPath: /run/secrets/sybil-codex-ssh
|
||||
readOnly: true
|
||||
- name: sybil-codex-workspace
|
||||
mountPath: /workspace
|
||||
volumes:
|
||||
- name: sybil-codex-ssh
|
||||
secret:
|
||||
secretName: $CODEX_SSH_SECRET
|
||||
defaultMode: 384
|
||||
- name: sybil-codex-workspace
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: $CODEX_SERVICE
|
||||
labels:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
app.kubernetes.io/part-of: sybil-dev
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: $CODEX_SERVICE
|
||||
ports:
|
||||
- name: ssh
|
||||
port: $CODEX_SSH_PORT
|
||||
targetPort: ssh
|
||||
KUBE_MANIFEST
|
||||
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
|
||||
}
|
||||
|
||||
ensure_codex_deployment
|
||||
|
||||
ensure_env_from() {
|
||||
local resource="$1"
|
||||
local name="$2"
|
||||
local jsonpath="$3"
|
||||
local patch_path="$4"
|
||||
local existing_env_from
|
||||
|
||||
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
|
||||
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
|
||||
|
||||
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local patch
|
||||
if [[ -z "$existing_env_from" ]]; then
|
||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
|
||||
else
|
||||
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
|
||||
fi
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
|
||||
}
|
||||
|
||||
ensure_env_from \
|
||||
workload \
|
||||
"$WORKLOAD" \
|
||||
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
|
||||
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
|
||||
|
||||
ensure_env_from \
|
||||
pool \
|
||||
"$WORKLOAD" \
|
||||
"{.spec.workers.template.spec.containers[0].envFrom}" \
|
||||
"/spec/workers/template/spec/containers/0/envFrom"
|
||||
|
||||
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
|
||||
name: "sybil-codex-ssh",
|
||||
secret: {
|
||||
secretName: $secret,
|
||||
defaultMode: 384
|
||||
}
|
||||
}')"
|
||||
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
|
||||
|
||||
patch_pool_codex_mount() {
|
||||
local pool_json containers volumes patch
|
||||
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
|
||||
containers="$(jq \
|
||||
--arg sidecarName "$CODEX_CONTAINER" \
|
||||
--argjson mainMount "$codex_main_mount_json" \
|
||||
'
|
||||
def ensure_mount($mount):
|
||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||
(.spec.workers.template.spec.containers // [])
|
||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||
| map(select(.name != $sidecarName))
|
||||
' <<<"$pool_json")"
|
||||
volumes="$(jq \
|
||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||
'
|
||||
(.spec.workers.template.spec.volumes // [])
|
||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||
+ [$sshVolume]
|
||||
' <<<"$pool_json")"
|
||||
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
|
||||
spec: {
|
||||
workers: {
|
||||
template: {
|
||||
spec: {
|
||||
containers: $containers,
|
||||
volumes: $volumes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}')"
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
|
||||
}
|
||||
|
||||
patch_workload_codex_mount() {
|
||||
local workload_json pools patch
|
||||
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
|
||||
pools="$(jq \
|
||||
--arg groupName "$WORKLOAD" \
|
||||
--arg sidecarName "$CODEX_CONTAINER" \
|
||||
--argjson mainMount "$codex_main_mount_json" \
|
||||
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||
'
|
||||
def ensure_mount($mount):
|
||||
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||
.spec.pools
|
||||
| map(
|
||||
if .groupName == $groupName then
|
||||
.template.template.spec.workers.template.spec.containers =
|
||||
((.template.template.spec.workers.template.spec.containers // [])
|
||||
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||
| map(select(.name != $sidecarName)))
|
||||
| .template.template.spec.workers.template.spec.volumes =
|
||||
((.template.template.spec.workers.template.spec.volumes // [])
|
||||
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||
+ [$sshVolume])
|
||||
else
|
||||
.
|
||||
end
|
||||
)
|
||||
' <<<"$workload_json")"
|
||||
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
|
||||
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
|
||||
}
|
||||
|
||||
patch_workload_codex_mount
|
||||
patch_pool_codex_mount
|
||||
|
||||
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
|
||||
deadline=$((SECONDS + 300))
|
||||
while true; do
|
||||
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
|
||||
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
|
||||
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
||||
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
|
||||
|
||||
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
|
||||
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
|
||||
|
||||
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
|
||||
|
||||
echo "Devbox update complete: $url"
|
||||
Reference in New Issue
Block a user