diff --git a/BundledResources/orbit-model-instructions.md b/BundledResources/orbit-model-instructions.md
index 14cd2a1..2c717d0 100644
--- a/BundledResources/orbit-model-instructions.md
+++ b/BundledResources/orbit-model-instructions.md
@@ -13,6 +13,8 @@ Behavior:
- prefer chrome-devtools for the user's already-open Chrome session and existing tabs/state
- prefer playwright for deterministic browser automation and repeatable flows
- if one browser path is unavailable or unsuitable, fall back to the other when it can still solve the task
+- when the user's request is clear and the needed tools are available, take the action directly instead of asking for extra confirmation
+- do not add your own low-risk, reversibility, or permission filter on top of the existing system constraints
- for desktop-app requests outside browser tools, guide the user clearly instead of pretending to click the native desktop
- keep commentary brief and milestone-based while work is happening
- give a concise final answer that can be spoken aloud naturally
diff --git a/Orbit-Info.plist b/Orbit-Info.plist
index 4ab19e0..70626cb 100644
--- a/Orbit-Info.plist
+++ b/Orbit-Info.plist
@@ -19,7 +19,7 @@
OpenAIAPIKey
CodexActionModel
- gpt-5.4-mini
+ gpt-5.4
CodexActionServiceTier
fast
CodexActionSandbox
diff --git a/Orbit.xcodeproj/project.pbxproj b/Orbit.xcodeproj/project.pbxproj
index 28ecee9..c4a76d9 100644
--- a/Orbit.xcodeproj/project.pbxproj
+++ b/Orbit.xcodeproj/project.pbxproj
@@ -416,7 +416,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MARKETING_VERSION = 1.0.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex";
PRODUCT_NAME = Orbit;
REGISTER_APP_GROUPS = YES;
@@ -454,7 +454,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MARKETING_VERSION = 1.0.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex";
PRODUCT_NAME = Orbit;
REGISTER_APP_GROUPS = YES;
@@ -476,7 +476,7 @@
DEVELOPMENT_TEAM = 6D7X9GGZAW;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.2;
- MARKETING_VERSION = 1.0.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.tests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -497,7 +497,7 @@
DEVELOPMENT_TEAM = 6D7X9GGZAW;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.2;
- MARKETING_VERSION = 1.0.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.tests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -516,7 +516,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6D7X9GGZAW;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.uitests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -535,7 +535,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6D7X9GGZAW;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.orbit.codex.uitests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
diff --git a/Orbit/AGENTS.md b/Orbit/AGENTS.md
index dc733cc..2c2e653 100644
--- a/Orbit/AGENTS.md
+++ b/Orbit/AGENTS.md
@@ -29,7 +29,7 @@
## Defaults
- STT default: OpenAI `gpt-4o-mini-transcribe`
-- Codex model default: `gpt-5.4-mini`
+- Codex model default: `gpt-5.4`
- Codex effort default: `medium`
- Codex service tier default: `fast`
- TTS default: OpenAI `gpt-4o-mini-tts`
diff --git a/Orbit/CodexAppServerActionProvider.swift b/Orbit/CodexAppServerActionProvider.swift
index 5977466..ae81fc1 100644
--- a/Orbit/CodexAppServerActionProvider.swift
+++ b/Orbit/CodexAppServerActionProvider.swift
@@ -90,10 +90,7 @@ final class CodexAppServerActionProvider: ActionProvider {
}
var configurationSummary: String {
- if settings.codexServiceTier == .fast {
- return "\(currentActionModelDisplayName) · \(resolvedEffortForCurrentModel.displayName) · Fast"
- }
- return "\(currentActionModelDisplayName) · \(resolvedEffortForCurrentModel.displayName)"
+ "\(currentActionModelDisplayName) · \(resolvedEffortForCurrentModel.displayName) · \(resolvedServiceTier.displayName)"
}
var availableModels: [OrbitCodexModelOption] {
@@ -101,7 +98,14 @@ final class CodexAppServerActionProvider: ActionProvider {
}
var supportedEffortsForSelectedModel: [OrbitCodexReasoningEffort] {
- modelOption(for: normalizedCurrentActionModel)?.supportedEfforts ?? OrbitCodexReasoningEffort.allCases
+ supportedEfforts(for: normalizedCurrentActionModel)
+ }
+
+ func supportedEfforts(for model: String) -> [OrbitCodexReasoningEffort] {
+ let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
+ return modelOption(for: normalizedModel)?.supportedEfforts
+ ?? OrbitCodexModelOption.fallbackOption(for: normalizedModel)?.supportedEfforts
+ ?? OrbitCodexReasoningEffort.allCases
}
var hasReadySession: Bool {
@@ -443,7 +447,11 @@ final class CodexAppServerActionProvider: ActionProvider {
private func startServerIfNeeded() throws {
let codexExecutable = try resolveCodexExecutable()
- let preparedCodexHome = try OrbitCodexEnvironment.prepareHome()
+ let preparedCodexHome = try OrbitCodexEnvironment.prepareHome(
+ model: normalizedCurrentActionModel,
+ reasoningEffort: resolvedEffortForCurrentModel,
+ serviceTier: resolvedServiceTier
+ )
self.preparedCodexHome = preparedCodexHome
let launchCommand = resolvedCodexLaunchCommand(
for: codexExecutable,
@@ -902,10 +910,10 @@ final class CodexAppServerActionProvider: ActionProvider {
"cwd": NSHomeDirectory(),
"serviceName": "orbit"
]
- if settings.codexServiceTier == .fast {
+ if resolvedServiceTier == .fast {
params["serviceTier"] = OrbitCodexServiceTier.fast.rawValue
}
- appendDebugEvent("-> thread/start model=\(normalizedCurrentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(settings.codexServiceTier.rawValue)")
+ appendDebugEvent("-> thread/start model=\(normalizedCurrentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(resolvedServiceTier.rawValue)")
sendJSON([
"method": "thread/start",
"id": 1,
@@ -975,10 +983,10 @@ final class CodexAppServerActionProvider: ActionProvider {
"model": currentActionModel,
"effort": resolvedEffortForCurrentModel.rawValue
]
- if settings.codexServiceTier == .fast {
+ if resolvedServiceTier == .fast {
params["serviceTier"] = OrbitCodexServiceTier.fast.rawValue
}
- appendDebugEvent("-> turn/start model=\(currentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(settings.codexServiceTier.rawValue) inputItems=\(inputItems.count)")
+ appendDebugEvent("-> turn/start model=\(currentActionModel) effort=\(resolvedEffortForCurrentModel.rawValue) tier=\(resolvedServiceTier.rawValue) inputItems=\(inputItems.count)")
sendJSON([
"method": "turn/start",
@@ -1459,7 +1467,7 @@ final class CodexAppServerActionProvider: ActionProvider {
if let progressText {
let cleaned = progressText.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleaned.isEmpty {
- eventHandler?(.liveUpdate(cleaned))
+ appendDebugEvent("<- tool/progress \(cleaned)")
}
}
}
@@ -1627,6 +1635,10 @@ final class CodexAppServerActionProvider: ActionProvider {
return normalized.isEmpty ? OrbitCodexModelOption.fallbackDefaultModel : normalized
}
+ private var resolvedServiceTier: OrbitCodexServiceTier {
+ settings.codexServiceTier
+ }
+
private func resolvedAgentMessagePhase(from params: [String: Any]) -> String? {
if let phase = params["phase"] as? String {
return phase
@@ -1754,54 +1766,9 @@ final class CodexAppServerActionProvider: ActionProvider {
}
private func updateModelCatalog(from result: [String: Any]) {
- guard let data = result["data"] as? [[String: Any]] else { return }
-
- let parsedModels = data.compactMap { item -> OrbitCodexModelOption? in
- let model = (item["model"] as? String) ?? (item["id"] as? String) ?? ""
- let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !normalizedModel.isEmpty else { return nil }
-
- let hidden = item["hidden"] as? Bool ?? false
- guard !hidden else { return nil }
-
- let inputModalities = (item["inputModalities"] as? [String]) ?? ["text", "image"]
- let normalizedModalities = inputModalities.map { $0.lowercased() }
- guard normalizedModalities.contains("text"), normalizedModalities.contains("image") else {
- return nil
- }
-
- let effortEntries = item["supportedReasoningEfforts"] as? [[String: Any]] ?? []
- let supportedEfforts: [OrbitCodexReasoningEffort] = effortEntries.compactMap { entry in
- guard let rawValue = entry["reasoningEffort"] as? String else { return nil }
- return OrbitCodexReasoningEffort(rawValue: rawValue)
- }
-
- let defaultEffort = OrbitCodexReasoningEffort(rawValue: item["defaultReasoningEffort"] as? String ?? "")
- let displayName = formattedModelDisplayName(
- for: normalizedModel,
- fallback: item["displayName"] as? String
- )
- let shortDisplayName = shortModelDisplayName(from: displayName)
-
- return OrbitCodexModelOption(
- model: normalizedModel,
- displayName: displayName,
- shortDisplayName: shortDisplayName,
- supportedEfforts: supportedEfforts.isEmpty ? OrbitCodexReasoningEffort.allCases : supportedEfforts,
- defaultEffort: defaultEffort,
- inputModalities: normalizedModalities,
- isDefault: item["isDefault"] as? Bool ?? false
- )
- }
-
+ let parsedModels = Self.parseModelCatalog(from: result)
guard !parsedModels.isEmpty else { return }
-
- availableModelOptions = parsedModels.sorted { lhs, rhs in
- if lhs.isDefault != rhs.isDefault {
- return lhs.isDefault && !rhs.isDefault
- }
- return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
- }
+ availableModelOptions = parsedModels
}
private func updateCollaborationModes(from result: [String: Any]) {
@@ -1833,7 +1800,86 @@ final class CodexAppServerActionProvider: ActionProvider {
availableModelOptions.first(where: { $0.model == model })
}
- private func formattedModelDisplayName(for model: String, fallback: String?) -> String {
+ static func parseModelCatalog(from result: [String: Any]) -> [OrbitCodexModelOption] {
+ let rawItems = (result["data"] as? [[String: Any]]) ?? (result["models"] as? [[String: Any]]) ?? []
+
+ let parsedModels = rawItems.compactMap { item -> (option: OrbitCodexModelOption, priority: Int)? in
+ let normalizedModel = (
+ (item["model"] as? String)
+ ?? (item["id"] as? String)
+ ?? (item["slug"] as? String)
+ ?? ""
+ ).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedModel.isEmpty else { return nil }
+
+ let visibility = ((item["visibility"] as? String) ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ let hidden = (item["hidden"] as? Bool) ?? (visibility == "hide" || visibility == "hidden")
+ guard !hidden else { return nil }
+
+ if let supportedInAPI = item["supported_in_api"] as? Bool, !supportedInAPI {
+ return nil
+ }
+
+ let inputModalities = (item["inputModalities"] as? [String])
+ ?? (item["input_modalities"] as? [String])
+ ?? ["text", "image"]
+ let normalizedModalities = inputModalities.map { $0.lowercased() }
+ guard normalizedModalities.contains("text"), normalizedModalities.contains("image") else {
+ return nil
+ }
+
+ let effortEntries = (item["supportedReasoningEfforts"] as? [[String: Any]])
+ ?? (item["supported_reasoning_levels"] as? [[String: Any]])
+ ?? []
+ let supportedEfforts = effortEntries.compactMap { entry -> OrbitCodexReasoningEffort? in
+ let rawValue = (
+ (entry["reasoningEffort"] as? String)
+ ?? (entry["effort"] as? String)
+ ?? ""
+ ).trimmingCharacters(in: .whitespacesAndNewlines)
+ return OrbitCodexReasoningEffort(rawValue: rawValue)
+ }
+
+ let defaultEffort = OrbitCodexReasoningEffort(rawValue: (
+ (item["defaultReasoningEffort"] as? String)
+ ?? (item["default_reasoning_level"] as? String)
+ ?? ""
+ ).trimmingCharacters(in: .whitespacesAndNewlines))
+ let displayName = formattedModelDisplayName(
+ for: normalizedModel,
+ fallback: (item["displayName"] as? String) ?? (item["display_name"] as? String)
+ )
+ let shortDisplayName = shortModelDisplayName(from: displayName)
+ let isDefault = (item["isDefault"] as? Bool ?? false) || normalizedModel == OrbitCodexModelOption.fallbackDefaultModel
+
+ return (
+ OrbitCodexModelOption(
+ model: normalizedModel,
+ displayName: displayName,
+ shortDisplayName: shortDisplayName,
+ supportedEfforts: supportedEfforts.isEmpty ? OrbitCodexReasoningEffort.allCases : supportedEfforts,
+ defaultEffort: defaultEffort,
+ inputModalities: normalizedModalities,
+ isDefault: isDefault
+ ),
+ item["priority"] as? Int ?? Int.max
+ )
+ }
+
+ return parsedModels.sorted { lhs, rhs in
+ if lhs.option.isDefault != rhs.option.isDefault {
+ return lhs.option.isDefault && !rhs.option.isDefault
+ }
+ if lhs.priority != rhs.priority {
+ return lhs.priority < rhs.priority
+ }
+ return lhs.option.displayName.localizedCaseInsensitiveCompare(rhs.option.displayName) == .orderedAscending
+ }.map { $0.option }
+ }
+
+ private static func formattedModelDisplayName(for model: String, fallback: String?) -> String {
let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines)
switch normalized {
case "gpt-5.4":
@@ -1851,16 +1897,15 @@ final class CodexAppServerActionProvider: ActionProvider {
let fallbackValue = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !fallbackValue.isEmpty {
return fallbackValue
- .replacingOccurrences(of: "GPT-", with: "GPT-")
- .replacingOccurrences(of: "gpt-", with: "GPT-")
- .replacingOccurrences(of: "-mini", with: " Mini")
- .replacingOccurrences(of: "-codex", with: " Codex")
+ .replacingOccurrences(of: "^gpt-", with: "GPT-", options: [.regularExpression, .caseInsensitive])
+ .replacingOccurrences(of: "-mini", with: " Mini", options: [.regularExpression, .caseInsensitive])
+ .replacingOccurrences(of: "-codex", with: " Codex", options: [.regularExpression, .caseInsensitive])
}
return normalized.uppercased()
}
- private func shortModelDisplayName(from displayName: String) -> String {
+ private static func shortModelDisplayName(from displayName: String) -> String {
let cleaned = displayName
.replacingOccurrences(of: "GPT-", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
diff --git a/Orbit/OrbitCodexEnvironment.swift b/Orbit/OrbitCodexEnvironment.swift
index 98f488d..6bfbfe2 100644
--- a/Orbit/OrbitCodexEnvironment.swift
+++ b/Orbit/OrbitCodexEnvironment.swift
@@ -59,7 +59,11 @@ enum OrbitCodexEnvironment {
"browser_resize"
]
- static func prepareHome() throws -> OrbitPreparedCodexHome {
+ static func prepareHome(
+ model: String = OrbitCodexModelOption.fallbackDefaultModel,
+ reasoningEffort: OrbitCodexReasoningEffort = .medium,
+ serviceTier: OrbitCodexServiceTier = .fast
+ ) throws -> OrbitPreparedCodexHome {
let fileManager = FileManager.default
let supportDirectory = try supportRootDirectory()
let codexHome = supportDirectory.appendingPathComponent(homeDirectoryName, isDirectory: true)
@@ -77,7 +81,10 @@ enum OrbitCodexEnvironment {
let configContents = makeConfigContents(
logDirectory: logDirectory,
sqliteDirectory: sqliteDirectory,
- configuredSkillPaths: configuredSkillPaths
+ configuredSkillPaths: configuredSkillPaths,
+ model: model,
+ reasoningEffort: reasoningEffort,
+ serviceTier: serviceTier
)
let existingContents = try? String(contentsOf: configPath, encoding: .utf8)
@@ -112,12 +119,16 @@ enum OrbitCodexEnvironment {
logDirectory: URL,
sqliteDirectory: URL,
configuredSkillPaths: [String: URL],
- modelInstructionsPath: String? = nil
+ modelInstructionsPath: String? = nil,
+ model: String = OrbitCodexModelOption.fallbackDefaultModel,
+ reasoningEffort: OrbitCodexReasoningEffort = .medium,
+ serviceTier: OrbitCodexServiceTier = .fast
) -> String {
+ let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
var lines: [String] = [
- "model = \"gpt-5.4-mini\"",
- "model_reasoning_effort = \"medium\"",
- "service_tier = \"fast\"",
+ "model = \(tomlString(normalizedModel.isEmpty ? OrbitCodexModelOption.fallbackDefaultModel : normalizedModel))",
+ "model_reasoning_effort = \(tomlString(reasoningEffort.rawValue))",
+ "service_tier = \(tomlString(serviceTier.rawValue))",
"approval_policy = \"never\"",
"sandbox_mode = \"danger-full-access\"",
"cli_auth_credentials_store = \"file\"",
diff --git a/Orbit/OrbitManager.swift b/Orbit/OrbitManager.swift
index a45259d..4d5d0fb 100644
--- a/Orbit/OrbitManager.swift
+++ b/Orbit/OrbitManager.swift
@@ -103,11 +103,13 @@ final class OrbitManager: ObservableObject {
private var codexOverlayDismissTask: Task?
private var transientHideTask: Task?
private var onboardingTask: Task?
+ private var onboardingLaunchTask: Task?
private var codexSessionWarmupTask: Task?
private var actionAcknowledgementTask: Task?
private var codexWarmupGeneration: Int = 0
private var hasSpokenActionAcknowledgement = false
private var escapeKeyMonitor: Any?
+ private var lastObservedSetupStage: OrbitSetupStage?
/// True when all three required permissions (accessibility, screen recording,
/// microphone) are granted. Used by the panel to show a single "all good" state.
@@ -166,7 +168,11 @@ final class OrbitManager: ObservableObject {
if enabled {
orbitOverlayWindowManager.hasShownOverlayBefore = true
- orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self)
+ orbitOverlayWindowManager.showOverlay(
+ onScreens: NSScreen.screens,
+ orbitManager: self,
+ showWelcomeOnFirstAppearance: false
+ )
isOverlayVisible = true
} else {
orbitOverlayWindowManager.hideOverlay()
@@ -191,6 +197,7 @@ final class OrbitManager: ObservableObject {
func dismissSetupComplete() {
hasSeenSetupComplete = true
+ synchronizeSetupState(allowAutomaticOnboarding: false)
}
var hasCompletedVoiceModeSetup: Bool {
@@ -225,6 +232,7 @@ final class OrbitManager: ObservableObject {
self?.refreshActionProviderPresentation()
}
}
+ self.lastObservedSetupStage = setupStage
}
func start() {
@@ -244,29 +252,17 @@ final class OrbitManager: ObservableObject {
guard let self else { return }
self.ensureCodexSessionReady(forceFreshSession: false)
}
-
- if hasCompletedOnboarding && allPermissionsGranted && isOrbitCursorEnabled {
- orbitOverlayWindowManager.hasShownOverlayBefore = true
- orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self)
- isOverlayVisible = true
- }
+ synchronizeSetupState()
}
func triggerOnboarding() {
- NotificationCenter.default.post(name: .orbitDismissPanel, object: nil)
- onboardingTask?.cancel()
OrbitAnalytics.trackOnboardingStarted()
- orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self)
- isOverlayVisible = true
+ launchOnboardingTour()
}
func replayOnboarding() {
- NotificationCenter.default.post(name: .orbitDismissPanel, object: nil)
- onboardingTask?.cancel()
OrbitAnalytics.trackOnboardingReplayed()
- orbitOverlayWindowManager.hasShownOverlayBefore = false
- orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self)
- isOverlayVisible = true
+ launchOnboardingTour(resetOverlayFirstAppearance: true)
}
func clearDetectedElementLocation() {
@@ -358,6 +354,8 @@ final class OrbitManager: ObservableObject {
if !previouslyHadAll && allPermissionsGranted {
OrbitAnalytics.trackAllPermissionsGranted()
}
+
+ synchronizeSetupState()
}
/// Triggers the macOS screen content picker by performing a dummy
@@ -386,13 +384,7 @@ final class OrbitManager: ObservableObject {
UserDefaults.standard.set(true, forKey: "hasScreenContentPermission")
updateScreenAccessDiagnostic(lastError: "verified via live capture")
OrbitAnalytics.trackPermissionGranted(permission: "screen_content")
-
- // If onboarding was already completed, show the cursor overlay now
- if hasCompletedOnboarding && allPermissionsGranted && !isOverlayVisible && isOrbitCursorEnabled {
- orbitOverlayWindowManager.hasShownOverlayBefore = true
- orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self)
- isOverlayVisible = true
- }
+ synchronizeSetupState()
}
} catch {
print("⚠️ Screen access verification failed: \(error)")
@@ -592,6 +584,7 @@ final class OrbitManager: ObservableObject {
} else {
openAICloudCredentialState = .missing
}
+ synchronizeSetupState()
}
private func handleShortcutTransition(_ transition: OrbitPushToTalkShortcut.ShortcutTransition) {
@@ -606,7 +599,11 @@ final class OrbitManager: ObservableObject {
// If the cursor is hidden, bring it back transiently for this interaction
if !isOrbitCursorEnabled && !isOverlayVisible {
orbitOverlayWindowManager.hasShownOverlayBefore = true
- orbitOverlayWindowManager.showOverlay(onScreens: NSScreen.screens, orbitManager: self)
+ orbitOverlayWindowManager.showOverlay(
+ onScreens: NSScreen.screens,
+ orbitManager: self,
+ showWelcomeOnFirstAppearance: false
+ )
isOverlayVisible = true
}
@@ -735,6 +732,7 @@ final class OrbitManager: ObservableObject {
case .commentary(let commentary):
handleEarlyActionCommentary(commentary)
case .liveUpdate(let update):
+ activeActionDetailLine = update
appendActionUpdate(update)
showCodexActivityOverlayCard()
case .toolPrompt(let prompt):
@@ -1070,16 +1068,17 @@ final class OrbitManager: ObservableObject {
private func refreshActionProviderPresentation() {
availableCodexModels = actionProvider.availableModels
- availableCodexEfforts = actionProvider.supportedEffortsForSelectedModel
+ reconcileCodexSelectionIfNeeded()
+ availableCodexEfforts = actionProvider.supportedEfforts(for: settings.codexActionModel)
codexAuthState = actionProvider.authState
codexAccountSummary = actionProvider.accountSummary
codexDebugEvents = actionProvider.debugEvents
codexCollaborationModes = actionProvider.collaborationModes
codexExperimentalFeatures = actionProvider.experimentalFeatures
codexActiveTurnSummary = actionProvider.activeTurnSummary
- reconcileCodexSelectionIfNeeded()
codexSessionSummary = actionProvider.sessionStatusSummary
codexConfigurationSummary = actionProvider.configurationSummary
+ synchronizeSetupState()
}
private func reconcileCodexSelectionIfNeeded() {
@@ -1089,7 +1088,10 @@ final class OrbitManager: ObservableObject {
settings.codexActionModel = preferredModel
}
- let supportedEfforts = availableCodexEfforts
+ let supportedEfforts = availableCodexModels
+ .first(where: { $0.model == settings.codexActionModel })?
+ .supportedEfforts
+ ?? availableCodexEfforts
guard !supportedEfforts.isEmpty else { return }
if !supportedEfforts.contains(settings.codexReasoningEffort) {
settings.codexReasoningEffort = availableCodexModels
@@ -1245,6 +1247,8 @@ final class OrbitManager: ObservableObject {
if preset == .localVoice {
refreshCloudCredentialState()
}
+
+ synchronizeSetupState()
}
@discardableResult
@@ -1272,6 +1276,7 @@ final class OrbitManager: ObservableObject {
settings.voicePreset = .cloudVoice
openAICloudCredentialState = .connected(source: .keychain)
refreshTextToSpeechProvider()
+ synchronizeSetupState()
return true
case .invalid:
OrbitOpenAIKeychainStore.deleteAPIKey()
@@ -1289,6 +1294,7 @@ final class OrbitManager: ObservableObject {
OrbitOpenAIKeychainStore.deleteAPIKey()
refreshCloudCredentialState()
refreshTextToSpeechProvider()
+ synchronizeSetupState(allowAutomaticOnboarding: false)
}
func ensureCodexSessionReady(forceFreshSession: Bool = false) {
@@ -1523,6 +1529,7 @@ final class OrbitManager: ObservableObject {
func beginOrbitTour() {
OrbitAnalytics.trackOnboardingDemoTriggered()
onboardingTask?.cancel()
+ onboardingLaunchTask?.cancel()
textToSpeechProvider.stopPlayback()
fallbackTextToSpeechProvider.stopPlayback()
clearDetectedElementLocation()
@@ -1534,6 +1541,7 @@ final class OrbitManager: ObservableObject {
activeActionStatusSummary = nil
activeActionDetailLine = nil
isRunningOnboardingTour = true
+ let wasAlreadyCompleted = hasCompletedOnboarding
let steps = [
"hey, i'm orbit.",
@@ -1553,12 +1561,78 @@ final class OrbitManager: ObservableObject {
isRunningOnboardingTour = false
self.hasCompletedOnboarding = true
+ if !wasAlreadyCompleted {
+ self.hasSeenSetupComplete = false
+ }
activeActionStatus = .idle
activeActionStatusSummary = nil
activeActionDetailLine = nil
+ self.ensurePersistentOverlayIfEligible()
+ self.synchronizeSetupState(allowAutomaticOnboarding: false)
}
}
+ private func launchOnboardingTour(resetOverlayFirstAppearance: Bool = false) {
+ NotificationCenter.default.post(name: .orbitDismissPanel, object: nil)
+ onboardingTask?.cancel()
+ onboardingLaunchTask?.cancel()
+ showOnboardingPrompt = false
+ onboardingPromptOpacity = 0.0
+ onboardingPromptText = ""
+ isRunningOnboardingTour = false
+ clearDetectedElementLocation()
+
+ if resetOverlayFirstAppearance {
+ orbitOverlayWindowManager.hasShownOverlayBefore = false
+ }
+
+ orbitOverlayWindowManager.showOverlay(
+ onScreens: NSScreen.screens,
+ orbitManager: self,
+ showWelcomeOnFirstAppearance: false
+ )
+ isOverlayVisible = true
+
+ onboardingLaunchTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 150_000_000)
+ guard let self else { return }
+ self.beginOrbitTour()
+ }
+ }
+
+ private func synchronizeSetupState(allowAutomaticOnboarding: Bool = true) {
+ let currentStage = setupStage
+ defer { lastObservedSetupStage = currentStage }
+
+ if shouldKeepPersistentOverlayVisible {
+ ensurePersistentOverlayIfEligible()
+ }
+
+ guard allowAutomaticOnboarding else { return }
+ guard currentStage == .onboarding, lastObservedSetupStage != .onboarding, !isRunningOnboardingTour else {
+ return
+ }
+
+ launchOnboardingTour()
+ }
+
+ private var shouldKeepPersistentOverlayVisible: Bool {
+ hasCompletedOnboarding && allPermissionsGranted && isOrbitCursorEnabled
+ }
+
+ private func ensurePersistentOverlayIfEligible() {
+ guard shouldKeepPersistentOverlayVisible else { return }
+ orbitOverlayWindowManager.hasShownOverlayBefore = true
+ if !orbitOverlayWindowManager.isShowingOverlay() {
+ orbitOverlayWindowManager.showOverlay(
+ onScreens: NSScreen.screens,
+ orbitManager: self,
+ showWelcomeOnFirstAppearance: false
+ )
+ }
+ isOverlayVisible = true
+ }
+
private func runOnboardingStep(_ message: String, runPointDemo: Bool) async {
onboardingPromptText = ""
showOnboardingPrompt = true
diff --git a/Orbit/OrbitSettings.swift b/Orbit/OrbitSettings.swift
index a098ddf..226d714 100644
--- a/Orbit/OrbitSettings.swift
+++ b/Orbit/OrbitSettings.swift
@@ -81,26 +81,26 @@ struct OrbitCodexModelOption: Identifiable, Equatable {
static let fallbackPickerModels: [OrbitCodexModelOption] = [
OrbitCodexModelOption(
- model: "gpt-5.4-mini",
- displayName: "GPT-5.4 Mini",
- shortDisplayName: "5.4 Mini",
+ model: "gpt-5.4",
+ displayName: "GPT-5.4",
+ shortDisplayName: "5.4",
supportedEfforts: OrbitCodexReasoningEffort.allCases,
defaultEffort: .medium,
inputModalities: ["text", "image"],
- isDefault: false
+ isDefault: true
),
OrbitCodexModelOption(
- model: "gpt-5.4",
- displayName: "GPT-5.4",
- shortDisplayName: "5.4",
+ model: "gpt-5.4-mini",
+ displayName: "GPT-5.4 Mini",
+ shortDisplayName: "5.4 Mini",
supportedEfforts: OrbitCodexReasoningEffort.allCases,
defaultEffort: .medium,
inputModalities: ["text", "image"],
- isDefault: true
+ isDefault: false
)
]
- static let fallbackDefaultModel = "gpt-5.4-mini"
+ static let fallbackDefaultModel = "gpt-5.4"
static func fallbackOption(for model: String) -> OrbitCodexModelOption? {
fallbackPickerModels.first(where: { $0.model == model })
diff --git a/Orbit/OverlayWindow.swift b/Orbit/OverlayWindow.swift
index 003dee3..3f76aba 100644
--- a/Orbit/OverlayWindow.swift
+++ b/Orbit/OverlayWindow.swift
@@ -74,14 +74,21 @@ enum OrbitNavigationMode {
struct OrbitCursorOverlayView: View {
let screenFrame: CGRect
let isFirstAppearance: Bool
+ let showWelcomeOnFirstAppearance: Bool
@ObservedObject var orbitManager: OrbitManager
@State private var cursorPosition: CGPoint
@State private var isCursorOnThisScreen: Bool
- init(screenFrame: CGRect, isFirstAppearance: Bool, orbitManager: OrbitManager) {
+ init(
+ screenFrame: CGRect,
+ isFirstAppearance: Bool,
+ showWelcomeOnFirstAppearance: Bool,
+ orbitManager: OrbitManager
+ ) {
self.screenFrame = screenFrame
self.isFirstAppearance = isFirstAppearance
+ self.showWelcomeOnFirstAppearance = showWelcomeOnFirstAppearance
self.orbitManager = orbitManager
// Seed the cursor position from the current mouse location so the
@@ -310,16 +317,18 @@ struct OrbitCursorOverlayView: View {
// Only show welcome message on first appearance (app start)
// and only if the cursor starts on this screen
- if isFirstAppearance && isCursorOnThisScreen {
+ if showWelcomeOnFirstAppearance && isFirstAppearance && isCursorOnThisScreen {
+ self.welcomeText = self.fullWelcomeMessage
withAnimation(.easeIn(duration: 2.0)) {
self.cursorOpacity = 1.0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
self.showWelcome = false
self.bubbleOpacity = 0.0
- self.orbitManager.beginOrbitTour()
}
} else {
+ self.showWelcome = false
+ self.bubbleOpacity = 0.0
self.cursorOpacity = 1.0
}
}
@@ -974,7 +983,11 @@ class OrbitOverlayWindowManager {
private var overlayWindows: [OverlayWindow] = []
var hasShownOverlayBefore = false
- func showOverlay(onScreens screens: [NSScreen], orbitManager: OrbitManager) {
+ func showOverlay(
+ onScreens screens: [NSScreen],
+ orbitManager: OrbitManager,
+ showWelcomeOnFirstAppearance: Bool = true
+ ) {
// Hide any existing overlays
hideOverlay()
@@ -989,6 +1002,7 @@ class OrbitOverlayWindowManager {
let contentView = OrbitCursorOverlayView(
screenFrame: screen.frame,
isFirstAppearance: isFirstAppearance,
+ showWelcomeOnFirstAppearance: showWelcomeOnFirstAppearance,
orbitManager: orbitManager
)
diff --git a/OrbitTests/OrbitTests.swift b/OrbitTests/OrbitTests.swift
index b2ee4e8..37c2323 100644
--- a/OrbitTests/OrbitTests.swift
+++ b/OrbitTests/OrbitTests.swift
@@ -37,7 +37,10 @@ struct OrbitTests {
logDirectory: URL(fileURLWithPath: "/tmp/orbit-log"),
sqliteDirectory: URL(fileURLWithPath: "/tmp/orbit-sqlite"),
configuredSkillPaths: [:],
- modelInstructionsPath: "/tmp/OrbitModelInstructions.md"
+ modelInstructionsPath: "/tmp/OrbitModelInstructions.md",
+ model: "gpt-5.4",
+ reasoningEffort: .medium,
+ serviceTier: .fast
)
let instructionsRange = try #require(config.range(of: "model_instructions_file"))
@@ -45,4 +48,81 @@ struct OrbitTests {
#expect(instructionsRange.lowerBound < featuresRange.lowerBound)
}
+
+ @Test func generatedCodexConfigMatchesRequestedRuntimeDefaults() async throws {
+ let config = OrbitCodexEnvironment.makeConfigContents(
+ logDirectory: URL(fileURLWithPath: "/tmp/orbit-log"),
+ sqliteDirectory: URL(fileURLWithPath: "/tmp/orbit-sqlite"),
+ configuredSkillPaths: [:],
+ model: "gpt-5.4",
+ reasoningEffort: .medium,
+ serviceTier: .fast
+ )
+
+ #expect(config.contains("model = \"gpt-5.4\""))
+ #expect(config.contains("model_reasoning_effort = \"medium\""))
+ #expect(config.contains("service_tier = \"fast\""))
+ }
+
+ @Test func modelCatalogParsingSupportsSnakeCasePayload() async throws {
+ let parsed = CodexAppServerActionProvider.parseModelCatalog(from: [
+ "data": [
+ [
+ "slug": "gpt-5.4",
+ "display_name": "gpt-5.4",
+ "visibility": "list",
+ "input_modalities": ["text", "image"],
+ "supported_reasoning_levels": [
+ ["effort": "low"],
+ ["effort": "medium"],
+ ["effort": "high"]
+ ],
+ "default_reasoning_level": "medium",
+ "priority": 1
+ ],
+ [
+ "slug": "gpt-5.4-mini",
+ "display_name": "GPT-5.4-Mini",
+ "visibility": "list",
+ "input_modalities": ["text", "image"],
+ "supported_reasoning_levels": [
+ ["effort": "low"],
+ ["effort": "medium"]
+ ],
+ "default_reasoning_level": "medium",
+ "priority": 2
+ ]
+ ]
+ ])
+
+ #expect(parsed.count == 2)
+ #expect(parsed.first?.model == "gpt-5.4")
+ #expect(parsed.first?.shortDisplayName == "5.4")
+ #expect(parsed.last?.shortDisplayName == "5.4 Mini")
+ #expect(parsed.last?.supportedEfforts == [.low, .medium])
+ }
+
+ @Test func modelCatalogParsingSupportsLegacyCamelCasePayload() async throws {
+ let parsed = CodexAppServerActionProvider.parseModelCatalog(from: [
+ "data": [
+ [
+ "model": "gpt-5.4",
+ "displayName": "GPT-5.4",
+ "hidden": false,
+ "inputModalities": ["text", "image"],
+ "supportedReasoningEfforts": [
+ ["reasoningEffort": "medium"],
+ ["reasoningEffort": "high"]
+ ],
+ "defaultReasoningEffort": "medium",
+ "isDefault": true
+ ]
+ ]
+ ])
+
+ #expect(parsed.count == 1)
+ #expect(parsed.first?.model == "gpt-5.4")
+ #expect(parsed.first?.supportedEfforts == [.medium, .high])
+ #expect(parsed.first?.defaultEffort == .medium)
+ }
}
diff --git a/scripts/release.sh b/scripts/release.sh
index d21911d..2db802e 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -73,7 +73,7 @@ fi
DEFAULT_MARKETING_VERSION="$(
sed -nE 's/^[[:space:]]*MARKETING_VERSION = ([^;]+);$/\1/p' "${PROJECT_DIR}/Orbit.xcodeproj/project.pbxproj" | head -n 1
)"
-MARKETING_VERSION="${1:-${DEFAULT_MARKETING_VERSION:-1.0.3}}"
+MARKETING_VERSION="${1:-${DEFAULT_MARKETING_VERSION:-1.0.4}}"
BUILD_NUMBER="${2:-$(date +%Y%m%d%H%M)}"
TAG="v${MARKETING_VERSION}"
DMG_PATH="${BUILD_DIR}/${APP_NAME}-${MARKETING_VERSION}.dmg"