adds quick question UI for mac catalyst target

This commit is contained in:
2026-08-21 17:28:08 -07:00
parent eb2b0d3ca0
commit 5f9fc82b86
20 changed files with 2015 additions and 2 deletions
+3
View File
@@ -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>
+21 -1
View File
@@ -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,
+8
View File
@@ -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 Sybils File menu."
let status: String
if let error = next.error {
status = error
statusLabel.textColor = .systemRed
} else {
statusLabel.textColor = .secondaryLabelColor
if next.isCheckingSession {
status = "Connecting to Sybil…"
} else if !next.isAuthenticated {
status = "Connect in Sybil before asking a question."
} else if next.isConverting {
status = "Opening in chat…"
} else if next.isSending {
status = next.toolSummaries.last ?? "Thinking…"
} else {
status = ""
}
}
statusLabel.stringValue = status
statusLabel.isHidden = status.isEmpty
}
func preparePromptForPresentation() {
// Select only when invoked, never on streamed updates or normal edits.
promptView.selectAll(nil)
}
func textDidChange(_ notification: Notification) {
guard !panelState.isBusy else { return }
promptView.needsDisplay = true
delegate?.quickQuestionPromptChanged(promptView.string)
}
private func addRow(_ view: NSView) {
stack.addArrangedSubview(view)
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
private func makeHeader() -> NSView {
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
icon.contentTintColor = .systemPurple
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
let title = NSTextField(labelWithString: "Quick Question")
title.font = .systemFont(ofSize: 14, weight: .semibold)
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
shortcutLabel.textColor = .secondaryLabelColor
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
close.isBordered = false
close.toolTip = "Close (Esc)"
close.setAccessibilityLabel("Close Quick Question")
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
row.spacing = 8
row.alignment = .centerY
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
return row
}
private func makeControls() -> NSView {
providerPopup.target = self
providerPopup.action = #selector(changeProvider)
providerPopup.setAccessibilityLabel("Quick question provider")
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
modelPopup.target = self
modelPopup.action = #selector(changeModel)
modelPopup.setAccessibilityLabel("Quick question model")
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
convertButton.target = self
convertButton.action = #selector(convertToChat)
convertButton.bezelStyle = .rounded
openAppButton.target = self
openAppButton.action = #selector(openApp)
openAppButton.bezelStyle = .rounded
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
sendButton.imagePosition = .imageOnly
sendButton.bezelStyle = .circular
sendButton.contentTintColor = .systemPurple
sendButton.target = self
sendButton.action = #selector(submit)
sendButton.setAccessibilityLabel("Ask Quick Question")
sendButton.toolTip = "Ask (Return)"
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
row.spacing = 8
row.alignment = .centerY
row.detachesHiddenViews = true
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
return row
}
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
view.drawsBackground = false
view.textColor = .labelColor
view.insertionPointColor = .labelColor
view.font = .systemFont(ofSize: fontSize)
view.textContainerInset = NSSize(width: 0, height: 4)
view.isHorizontallyResizable = false
view.isVerticallyResizable = true
view.autoresizingMask = [.width]
view.textContainer?.widthTracksTextView = true
view.textContainer?.lineFragmentPadding = 0
}
private func makeScrollView(documentView: NSView) -> NSScrollView {
let scroll = NSScrollView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.documentView = documentView
return scroll
}
private static func renderAnswer(_ text: String) -> NSAttributedString {
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
for run in parsed.runs {
let intent = run.inlinePresentationIntent ?? []
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
}
return rendered
}
@objc private func closePanel() { onDismiss?() }
@objc private func changeProvider() {
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
delegate?.quickQuestionProviderChanged(provider)
}
@objc private func changeModel() {
guard let model = modelPopup.titleOfSelectedItem else { return }
delegate?.quickQuestionModelChanged(model)
}
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
@objc private func submit() {
guard panelState.canSend, !promptView.hasMarkedText() else { return }
delegate?.quickQuestionSubmitRequested()
}
}
@MainActor
final class QuickQuestionPromptView: NSTextView {
var onSubmit: (() -> Void)?
var onToggle: (() -> Void)?
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty else { return }
("Ask anything…" as NSString).draw(
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
)
}
override func keyDown(with event: NSEvent) {
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 49, modifiers == .option {
if !event.isARepeat { onToggle?() }
return
}
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
if !event.isARepeat { onSubmit?() }
return
}
// Let the text input system handle Shift+Return and IME confirmation.
super.keyDown(with: event)
}
}
@@ -0,0 +1,25 @@
targets:
SybilMacQuickQuestion:
type: bundle
platform: macOS
deploymentTarget: "15.0"
sources:
- Sources
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
dependencies:
- sdk: AppKit.framework
- sdk: Carbon.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
PRODUCT_NAME: SybilMacQuickQuestion
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
SUPPORTS_MACCATALYST: NO
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
MACH_O_TYPE: mh_bundle
SKIP_INSTALL: YES
@@ -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
+76
View File
@@ -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.
+6
View File
@@ -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
+1
View File
@@ -7,3 +7,4 @@ packages:
path: Packages/Sybil
include:
- Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/project.yml
+90
View File
@@ -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"
+357
View File
@@ -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"