From 5f9fc82b86bb6ae1e1874ef2c92532be8139b1ce Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 21 Aug 2026 17:28:08 -0700 Subject: [PATCH] adds quick question UI for mac catalyst target --- ios/Apps/Sybil/Info.plist | 3 + ios/Apps/Sybil/Sources/SybilApp.swift | 22 +- ios/Apps/Sybil/project.yml | 8 + .../Sources/MacQuickQuestionPlugin.swift | 229 +++++++++ .../Sources/QuickQuestionPanelView.swift | 300 ++++++++++++ ios/Apps/SybilMacQuickQuestion/project.yml | 25 + .../Sybil/Sources/Sybil/SplitView.swift | 54 +++ .../Sybil/SybilMacQuickQuestionBridge.swift | 71 +++ .../SybilMacQuickQuestionController.swift | 257 +++++++++++ .../Sybil/SybilMacWindowCommands.swift | 37 ++ .../Sybil/SybilQuickQuestionView.swift | 4 + .../Sybil/SybilReturnKeySubmission.swift | 35 ++ .../Sybil/Sources/Sybil/SybilViewModel.swift | 5 +- .../Sources/Sybil/SybilWorkspaceView.swift | 1 + .../Sybil/Tests/SybilTests/SybilTests.swift | 436 ++++++++++++++++++ ios/README.md | 76 +++ ios/justfile | 6 + ios/project.yml | 1 + scripts/devbox-start.sh | 90 ++++ scripts/devbox-update.sh | 357 ++++++++++++++ 20 files changed, 2015 insertions(+), 2 deletions(-) create mode 100644 ios/Apps/SybilMacQuickQuestion/Sources/MacQuickQuestionPlugin.swift create mode 100644 ios/Apps/SybilMacQuickQuestion/Sources/QuickQuestionPanelView.swift create mode 100644 ios/Apps/SybilMacQuickQuestion/project.yml create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionController.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilMacWindowCommands.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilReturnKeySubmission.swift create mode 100644 ios/README.md create mode 100755 scripts/devbox-start.sh create mode 100755 scripts/devbox-update.sh diff --git a/ios/Apps/Sybil/Info.plist b/ios/Apps/Sybil/Info.plist index 154b30b..e9a05b8 100644 --- a/ios/Apps/Sybil/Info.plist +++ b/ios/Apps/Sybil/Info.plist @@ -2,6 +2,9 @@ + + NSSupportsAutomaticTermination + UIApplicationShortcutItems diff --git a/ios/Apps/Sybil/Sources/SybilApp.swift b/ios/Apps/Sybil/Sources/SybilApp.swift index df206cf..7e334a2 100644 --- a/ios/Apps/Sybil/Sources/SybilApp.swift +++ b/ios/Apps/Sybil/Sources/SybilApp.swift @@ -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, diff --git a/ios/Apps/Sybil/project.yml b/ios/Apps/Sybil/project.yml index a38b2bf..9a33e9c 100644 --- a/ios/Apps/Sybil/project.yml +++ b/ios/Apps/Sybil/project.yml @@ -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 diff --git a/ios/Apps/SybilMacQuickQuestion/Sources/MacQuickQuestionPlugin.swift b/ios/Apps/SybilMacQuickQuestion/Sources/MacQuickQuestionPlugin.swift new file mode 100644 index 0000000..3d2d79e --- /dev/null +++ b/ios/Apps/SybilMacQuickQuestion/Sources/MacQuickQuestionPlugin.swift @@ -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.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.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?() } +} diff --git a/ios/Apps/SybilMacQuickQuestion/Sources/QuickQuestionPanelView.swift b/ios/Apps/SybilMacQuickQuestion/Sources/QuickQuestionPanelView.swift new file mode 100644 index 0000000..c0d27d8 --- /dev/null +++ b/ios/Apps/SybilMacQuickQuestion/Sources/QuickQuestionPanelView.swift @@ -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) + } +} diff --git a/ios/Apps/SybilMacQuickQuestion/project.yml b/ios/Apps/SybilMacQuickQuestion/project.yml new file mode 100644 index 0000000..b6bdf0f --- /dev/null +++ b/ios/Apps/SybilMacQuickQuestion/project.yml @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index 5b235a8..c066348 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -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 { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift b/ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift new file mode 100644 index 0000000..48d650c --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift @@ -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 + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionController.swift b/ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionController.swift new file mode 100644 index 0000000..58999a3 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionController.swift @@ -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? + 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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilMacWindowCommands.swift b/ios/Packages/Sybil/Sources/Sybil/SybilMacWindowCommands.swift new file mode 100644 index 0000000..7355181 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilMacWindowCommands.swift @@ -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 = [] + + 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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift index 88d6aa9..7faec6f 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift @@ -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.. 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 + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 563f859..0dbe5d9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 69843d7..9aa179c 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -601,6 +601,7 @@ struct SybilWorkspaceView: View { .onSubmit { submitComposer() } + .submitOnMacReturn(submitComposer) .padding(.horizontal, 12) .padding(.vertical, 10) .background( diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 95eae1d..3e223c8 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -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 diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..ec035d7 --- /dev/null +++ b/ios/README.md @@ -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. diff --git a/ios/justfile b/ios/justfile index 6e07e09..ff23138 100644 --- a/ios/justfile +++ b/ios/justfile @@ -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 diff --git a/ios/project.yml b/ios/project.yml index 9b67746..bb23cd2 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -7,3 +7,4 @@ packages: path: Packages/Sybil include: - Apps/Sybil/project.yml + - Apps/SybilMacQuickQuestion/project.yml diff --git a/scripts/devbox-start.sh b/scripts/devbox-start.sh new file mode 100755 index 0000000..26e2789 --- /dev/null +++ b/scripts/devbox-start.sh @@ -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" diff --git a/scripts/devbox-update.sh b/scripts/devbox-update.sh new file mode 100755 index 0000000..9b7c2da --- /dev/null +++ b/scripts/devbox-update.sh @@ -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 - </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 </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"