diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index ce6d25202ae..ae441c2de6b 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -2,6 +2,13 @@ import Foundation @MainActor enum CLIInstaller { + struct PreflightStatus: Equatable { + let needsCommandLineTools: Bool + let message: String? + + static let ready = PreflightStatus(needsCommandLineTools: false, message: nil) + } + static func installedLocation() -> String? { self.installedLocation( searchPaths: CommandResolver.preferredPaths(), @@ -34,6 +41,50 @@ enum CLIInstaller { self.installedLocation() != nil } + static func preflight() async -> PreflightStatus { + let response = await ShellExecutor.runDetailed( + command: ["/usr/bin/xcode-select", "-p"], + cwd: nil, + env: nil, + timeout: 10) + + guard response.success else { + return PreflightStatus( + needsCommandLineTools: true, + message: """ + Apple Developer Tools are required before OpenClaw can install the CLI. + Install them first, then come back and click “I've Installed It, Recheck”. + """) + } + + return .ready + } + + static func requestCommandLineToolsInstall( + statusHandler: @escaping @MainActor @Sendable (String) async -> Void + ) async { + await statusHandler("Opening Apple developer tools installer…") + let response = await ShellExecutor.runDetailed( + command: ["/usr/bin/xcode-select", "--install"], + cwd: nil, + env: nil, + timeout: 10) + + let combined = [response.stdout, response.stderr] + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + if combined.contains("already installed") || combined.contains("softwareupdate") { + await statusHandler( + "Apple Developer Tools installer is already open or installed. Finish that step, then click “I've Installed It, Recheck”.") + return + } + + await statusHandler( + "Complete Apple's developer tools installer dialog, then click “I've Installed It, Recheck”.") + } + static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let prefix = Self.installPrefix() diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index 40079453974..1a29806404a 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -14,6 +14,10 @@ enum CronCustomSessionTarget: Codable, Equatable { case predefined(CronSessionTarget) case session(id: String) + static let main: CronCustomSessionTarget = .predefined(.main) + static let isolated: CronCustomSessionTarget = .predefined(.isolated) + static let current: CronCustomSessionTarget = .predefined(.current) + var rawValue: String { switch self { case .predefined(let target): @@ -254,6 +258,38 @@ struct CronJob: Identifiable, Codable, Equatable { case state } + init( + id: String, + agentId: String?, + name: String, + description: String?, + enabled: Bool, + deleteAfterRun: Bool?, + createdAtMs: Int, + updatedAtMs: Int, + schedule: CronSchedule, + sessionTarget: CronCustomSessionTarget, + wakeMode: CronWakeMode, + payload: CronPayload, + delivery: CronDelivery?, + state: CronJobState + ) { + self.id = id + self.agentId = agentId + self.name = name + self.description = description + self.enabled = enabled + self.deleteAfterRun = deleteAfterRun + self.createdAtMs = createdAtMs + self.updatedAtMs = updatedAtMs + self.schedule = schedule + self.sessionTargetRaw = sessionTarget.rawValue + self.wakeMode = wakeMode + self.payload = payload + self.delivery = delivery + self.state = state + } + /// Parsed session target (predefined or custom session ID) var parsedSessionTarget: CronCustomSessionTarget { CronCustomSessionTarget.from(self.sessionTargetRaw) diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift b/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift index 4b51a4a9e9c..d706bfe5f58 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift @@ -15,7 +15,7 @@ struct CronSettings_Previews: PreviewProvider { createdAtMs: 0, updatedAtMs: 0, schedule: .every(everyMs: 86_400_000, anchorMs: nil), - sessionTarget: .isolated, + sessionTarget: .predefined(.isolated), wakeMode: .now, payload: .agentTurn( message: "Summarize inbox", @@ -69,7 +69,7 @@ extension CronSettings { createdAtMs: 1_700_000_000_000, updatedAtMs: 1_700_000_100_000, schedule: .cron(expr: "0 8 * * *", tz: "UTC"), - sessionTarget: .isolated, + sessionTarget: .predefined(.isolated), wakeMode: .nextHeartbeat, payload: .agentTurn( message: "Summarize", diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift index 0586e19ff70..242ce2221b5 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -90,7 +90,18 @@ enum GatewayEnvironment { } static func expectedGatewayVersionString() -> String? { - let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + self.expectedGatewayVersionString( + bundleVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + bundleIdentifier: Bundle.main.bundleIdentifier) + } + + static func expectedGatewayVersionString(bundleVersion: String?, bundleIdentifier: String?) -> String? { + if let bundleIdentifier, + bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".debug") + { + return nil + } + let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) return (trimmed?.isEmpty == false) ? trimmed : nil } diff --git a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift index e3d5263e9bc..40fa0b01046 100644 --- a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import OpenClawKit @MainActor @Observable @@ -196,13 +197,9 @@ final class GatewayProcessManager { let instanceText = instance.map { self.describe(instance: $0) } let hasListener = instance != nil - let attemptAttach = { - try await self.connection.requestRaw(method: .health, timeoutMs: 2000) - } - for attempt in 0..<(hasListener ? 3 : 1) { do { - let data = try await attemptAttach() + let data = try await self.probeLocalGatewayHealth(timeoutMs: 2000) let snap = decodeHealthSnapshot(from: data) let details = self.describe(details: instanceText, port: port, snap: snap) self.existingGatewayDetails = details @@ -337,7 +334,7 @@ final class GatewayProcessManager { while Date() < deadline { if !self.desiredActive { return } do { - _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + _ = try await self.probeLocalGatewayHealth(timeoutMs: 1500) let instance = await PortGuardian.shared.describe(port: port) let details = instance.map { "pid \($0.pid)" } self.clearLastFailure() @@ -380,7 +377,7 @@ final class GatewayProcessManager { while Date() < deadline { if !self.desiredActive { return false } do { - _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + _ = try await self.probeLocalGatewayHealth(timeoutMs: 1500) self.clearLastFailure() return true } catch { @@ -413,6 +410,20 @@ final class GatewayProcessManager { if text.count <= limit { return text } return String(text.suffix(limit)) } + + private func probeLocalGatewayHealth(timeoutMs: Double) async throws -> Data { + let config = GatewayEndpointStore.localConfig() + let channel = GatewayChannelActor( + url: config.url, + token: config.token, + password: config.password) + defer { + Task { + await channel.shutdown() + } + } + return try await channel.request(method: GatewayConnection.Method.health.rawValue, params: nil, timeoutMs: timeoutMs) + } } #if DEBUG diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 5e093c49e24..fda6730da43 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -33,6 +33,7 @@ final class MacNodeModeCoordinator { var retryDelay: UInt64 = 1_000_000_000 var lastCameraEnabled: Bool? var lastBrowserControlEnabled: Bool? + var lastBlockedOnOnboarding = false let defaults = UserDefaults.standard while !Task.isCancelled { @@ -41,6 +42,20 @@ final class MacNodeModeCoordinator { continue } + let onboardingComplete = Self.shouldConnectNodeMode( + onboardingSeen: defaults.bool(forKey: onboardingSeenKey), + onboardingVersion: defaults.integer(forKey: onboardingVersionKey)) + if !onboardingComplete { + if !lastBlockedOnOnboarding { + self.logger.info("mac node waiting for onboarding completion") + lastBlockedOnOnboarding = true + } + await self.session.disconnect() + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + lastBlockedOnOnboarding = false + let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false if lastCameraEnabled == nil { lastCameraEnabled = cameraEnabled @@ -116,6 +131,10 @@ final class MacNodeModeCoordinator { } } + static func shouldConnectNodeMode(onboardingSeen: Bool, onboardingVersion: Int) -> Bool { + onboardingSeen && onboardingVersion >= currentOnboardingVersion + } + private func currentCaps() -> [String] { var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] if OpenClawConfigFile.browserControlEnabled() { diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index ca183d35311..e9cdd8ee240 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -67,6 +67,8 @@ struct OnboardingView: View { @State var isRequesting = false @State var installingCLI = false @State var cliStatus: String? + @State var cliPreflightStatus: String? + @State var cliNeedsCommandLineTools = false @State var copied = false @State var monitoringPermissions = false @State var monitoringDiscovery = false @@ -97,6 +99,7 @@ struct OnboardingView: View { let pageWidth: CGFloat = Self.windowWidth let contentHeight: CGFloat = 460 let connectionPageIndex = 1 + let cliPageIndex = 6 let wizardPageIndex = 3 let onboardingChatPageIndex = 8 @@ -113,7 +116,7 @@ struct OnboardingView: View { case .unconfigured: showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9] case .local: - showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9] + showOnboardingChat ? [0, 1, 6, 3, 5, 8, 9] : [0, 1, 6, 3, 5, 9] } } @@ -146,7 +149,10 @@ struct OnboardingView: View { } var canAdvance: Bool { - !self.isWizardBlocking + if self.activePageIndex == self.cliPageIndex { + return self.cliInstalled && !self.installingCLI + } + return !self.isWizardBlocking } var devLinkCommand: String { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index 7ea549d9abb..8050289c12d 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -4,7 +4,10 @@ import SwiftUI extension OnboardingView { var body: some View { VStack(spacing: 0) { - GlowingOpenClawIcon(size: 130, glowIntensity: 0.28) + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 130, height: 130) + .clipShape(RoundedRectangle(cornerRadius: 130 * 0.22, style: .continuous)) .offset(y: 10) .frame(height: 145) @@ -46,10 +49,6 @@ extension OnboardingView { self.currentPage = max(0, self.pageOrder.count - 1) } } - .onChange(of: self.onboardingWizard.isComplete) { _, newValue in - guard newValue, self.activePageIndex == self.wizardPageIndex else { return } - self.handleNext() - } .onDisappear { self.stopPermissionMonitoring() self.stopDiscovery() @@ -57,7 +56,7 @@ extension OnboardingView { } .task { await self.refreshPerms() - self.refreshCLIStatus() + await self.refreshCLIInstallerReadiness() await self.loadWorkspaceDefaults() await self.ensureDefaultWorkspace() self.refreshBootstrapStatus() @@ -156,6 +155,16 @@ extension OnboardingView { .frame(width: self.pageWidth, alignment: .top) } + func onboardingFixedPage(@ViewBuilder _ content: () -> some View) -> some View { + VStack(spacing: 16) { + content() + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 28) + .frame(width: self.pageWidth, alignment: .top) + } + func onboardingCard( spacing: CGFloat = 12, padding: CGFloat = 16, @@ -166,10 +175,6 @@ extension OnboardingView { } .padding(padding) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor)) - .shadow(color: .black.opacity(0.06), radius: 8, y: 3)) } func onboardingGlassCard( diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index e7150edc55b..e1d83b684ae 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -43,6 +43,11 @@ extension OnboardingView { self.updatePermissionMonitoring(for: pageIndex) self.updateDiscoveryMonitoring(for: pageIndex) self.maybeKickoffOnboardingChat(for: pageIndex) + if pageIndex == self.cliPageIndex { + Task { @MainActor in + await self.refreshCLIInstallerReadiness() + } + } } func stopPermissionMonitoring() { @@ -57,12 +62,20 @@ extension OnboardingView { func installCLI() async { guard !self.installingCLI else { return } + await self.refreshCLIInstallerReadiness() + + if self.cliNeedsCommandLineTools { + await self.requestCommandLineToolsInstall() + return + } + self.installingCLI = true defer { installingCLI = false } await CLIInstaller.install { message in self.cliStatus = message } self.refreshCLIStatus() + await self.refreshCLIInstallerReadiness() } func refreshCLIStatus() { @@ -71,6 +84,29 @@ extension OnboardingView { self.cliInstalled = installLocation != nil } + @MainActor + func refreshCLIInstallerReadiness() async { + self.refreshCLIStatus() + + if self.cliInstalled { + self.cliNeedsCommandLineTools = false + self.cliPreflightStatus = nil + return + } + + let preflight = await CLIInstaller.preflight() + self.cliNeedsCommandLineTools = preflight.needsCommandLineTools + self.cliPreflightStatus = preflight.message + } + + @MainActor + func requestCommandLineToolsInstall() async { + await CLIInstaller.requestCommandLineToolsInstall { message in + self.cliPreflightStatus = message + } + await self.refreshCLIInstallerReadiness() + } + func refreshLocalGatewayProbe() async { let port = GatewayEnvironment.gatewayPort() let desc = await PortGuardian.shared.describe(port: port) diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index f35e4e4c4ec..186f99abf1c 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -33,7 +33,7 @@ extension OnboardingView { VStack(spacing: 22) { Text("Welcome to OpenClaw") .font(.largeTitle.weight(.semibold)) - Text("OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.") + Text("OpenClaw is a powerful personal AI assistant that connects to the apps you already use — WhatsApp, Telegram, Slack, and more.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -64,6 +64,13 @@ extension OnboardingView { } } } + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.06), radius: 8, y: 3)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.orange.opacity(0.06))) .frame(maxWidth: 520) } .padding(.top, 16) @@ -633,57 +640,92 @@ extension OnboardingView { self.onboardingPage { Text("Install the CLI") .font(.largeTitle.weight(.semibold)) - Text("Required for local mode: installs `openclaw` so launchd can run the gateway.") + Text( + self.cliNeedsCommandLineTools + ? "OpenClaw needs Apple Developer Tools first. Install those, then come back to install the CLI." + : "Installs the OpenClaw command-line tool so the gateway can run in the background.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 520) .fixedSize(horizontal: false, vertical: true) - self.onboardingCard(spacing: 10) { - HStack(spacing: 12) { - Button { - Task { await self.installCLI() } - } label: { - let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI" - ZStack { - Text(title) - .opacity(self.installingCLI ? 0 : 1) - if self.installingCLI { - ProgressView() - .controlSize(.mini) + self.onboardingCard(spacing: 12) { + Button { + Task { + if self.cliNeedsCommandLineTools { + await self.requestCommandLineToolsInstall() + } else { + await self.installCLI() + } + } + } label: { + let title: String = if self.cliNeedsCommandLineTools { + "Install Apple Developer Tools" + } else if self.cliInstalled { + "Reinstall CLI" + } else { + "Install CLI" + } + ZStack { + Text(title) + .opacity(self.installingCLI ? 0 : 1) + if self.installingCLI { + ProgressView() + .controlSize(.mini) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(self.installingCLI) + + if self.cliNeedsCommandLineTools { + HStack(spacing: 10) { + Button("I've Installed It, Recheck") { + Task { await self.refreshCLIInstallerReadiness() } + } + .buttonStyle(.bordered) + + Button("Open Software Update") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preferences.softwareupdate") { + NSWorkspace.shared.open(url) } } - .frame(minWidth: 120) - } - .buttonStyle(.borderedProminent) - .disabled(self.installingCLI) - - Button(self.copied ? "Copied" : "Copy install command") { - self.copyToPasteboard(self.devLinkCommand) - } - .disabled(self.installingCLI) - - if self.cliInstalled, let loc = self.cliInstallLocation { - Label("Installed at \(loc)", systemImage: "checkmark.circle.fill") - .font(.footnote) - .foregroundStyle(.green) + .buttonStyle(.bordered) } } - if let cliStatus { + if self.cliInstalled, let loc = self.cliInstallLocation { + Label("Installed at \(loc)", systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) + } + + if let cliPreflightStatus, self.cliNeedsCommandLineTools { + Text(cliPreflightStatus) + .font(.caption) + .foregroundStyle(.secondary) + } else if let cliStatus { Text(cliStatus) .font(.caption) .foregroundStyle(.secondary) } else if !self.cliInstalled, self.cliInstallLocation == nil { - Text( - """ - Installs a user-space Node 22+ runtime and the CLI (no Homebrew). - Rerun anytime to reinstall or update. - """) + Text("Installs a user-space Node 22+ runtime (no Homebrew required).") .font(.footnote) .foregroundStyle(.secondary) } + + Divider() + + Text("Prefer to install manually?") + .font(.footnote) + .foregroundStyle(.secondary) + Button(self.copied ? "Copied" : "Copy install command") { + self.copyToPasteboard(self.devLinkCommand) + } + .buttonStyle(.bordered) + .disabled(self.installingCLI) } } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift index 0c77f1e327d..d4af0c2e2e7 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift @@ -4,11 +4,11 @@ import SwiftUI extension OnboardingView { func wizardPage() -> some View { - self.onboardingPage { + self.onboardingFixedPage { VStack(spacing: 16) { - Text("Setup Wizard") + Text("Configure OpenClaw") .font(.largeTitle.weight(.semibold)) - Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.") + Text("Follow the steps below to configure your AI provider and gateway.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift index 75b9522a4d1..bd237dbc209 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift @@ -193,28 +193,13 @@ final class OnboardingWizardModel { private func shouldSkipWizard() -> Bool { let root = OpenClawConfigFile.loadDict() + return Self.shouldSkipWizard(root: root) + } + + static func shouldSkipWizard(root: [String: Any]) -> Bool { if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { return true } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any] - { - if let mode = auth["mode"] as? String, - !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let token = auth["token"] as? String, - !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let password = auth["password"] as? String, - !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - } return false } } @@ -254,17 +239,19 @@ struct OnboardingWizardStepView: View { } var body: some View { + Group { + if wizardStepType(self.step) == "select" { + self.selectStepLayout + } else { + self.standardStepLayout + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var standardStepLayout: some View { VStack(alignment: .leading, spacing: 12) { - if let title = step.title, !title.isEmpty { - Text(title) - .font(.title2.weight(.semibold)) - } - if let message = step.message, !message.isEmpty { - Text(message) - .font(.body) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } + self.stepHeader switch wizardStepType(self.step) { case "note": @@ -274,8 +261,6 @@ struct OnboardingWizardStepView: View { case "confirm": Toggle("", isOn: self.$confirmValue) .toggleStyle(.switch) - case "select": - self.selectOptions case "multiselect": self.multiselectOptions case "progress": @@ -288,14 +273,63 @@ struct OnboardingWizardStepView: View { .foregroundStyle(.secondary) } - Button(action: self.submit) { - Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") - .frame(minWidth: 120) - } - .buttonStyle(.borderedProminent) - .disabled(self.isSubmitting || self.isBlocked) + self.primaryActionButton } - .frame(maxWidth: .infinity, alignment: .leading) + } + + private var selectStepLayout: some View { + VStack(alignment: .leading, spacing: 12) { + self.stepHeader + + ScrollView { + self.selectOptions + .padding(.vertical, 2) + } + .frame(minHeight: 220, maxHeight: 320) + + Divider() + + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Selected: \(self.selectedOptionLabel)") + .font(.subheadline.weight(.medium)) + if let hint = self.selectedOptionHint { + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + + Spacer(minLength: 12) + + self.primaryActionButton + } + } + } + + @ViewBuilder + private var stepHeader: some View { + if let title = step.title, !title.isEmpty { + Text(title) + .font(.title2.weight(.semibold)) + } + if let message = step.message, !message.isEmpty { + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var primaryActionButton: some View { + Button(action: self.submit) { + Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(self.isSubmitting || self.isBlocked) } @ViewBuilder @@ -332,11 +366,12 @@ struct OnboardingWizardStepView: View { Button { self.selectedIndex = item.index } label: { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 10) { Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle") .foregroundStyle(Color.accentColor) VStack(alignment: .leading, spacing: 2) { Text(item.option.label) + .font(.body.weight(self.selectedIndex == item.index ? .semibold : .regular)) .foregroundStyle(.primary) if let hint = item.option.hint, !hint.isEmpty { Text(hint) @@ -344,7 +379,10 @@ struct OnboardingWizardStepView: View { .foregroundStyle(.secondary) } } + Spacer(minLength: 0) } + .padding(.vertical, 6) + .contentShape(Rectangle()) } .buttonStyle(.plain) } @@ -381,6 +419,22 @@ struct OnboardingWizardStepView: View { return false } + private var selectedOptionLabel: String { + guard self.optionItems.indices.contains(self.selectedIndex) else { + return "None" + } + return self.optionItems[self.selectedIndex].option.label + } + + private var selectedOptionHint: String? { + guard self.optionItems.indices.contains(self.selectedIndex) else { + return nil + } + let hint = self.optionItems[self.selectedIndex].option.hint?.trimmingCharacters( + in: .whitespacesAndNewlines) + return hint?.isEmpty == false ? hint : nil + } + private func submit() { switch wizardStepType(self.step) { case "note", "progress": diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 95bb74d1c14..d268cb708c9 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -37,22 +37,16 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "setup-token + API key", choices: ["token", "apiKey"], }, - { - value: "chutes", - label: "Chutes", - hint: "OAuth", - choices: ["chutes"], - }, { value: "minimax", label: "MiniMax", - hint: "M2.5 (recommended)", + hint: "OAuth or API key · M2.5", choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"], }, { value: "moonshot", label: "Moonshot AI (Kimi K2.5)", - hint: "Kimi K2.5 + Kimi Coding", + hint: "API key · Kimi K2.5 + Kimi Coding", choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"], }, { @@ -67,6 +61,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "API key", choices: ["xai-api-key"], }, + { + value: "chutes", + label: "Chutes", + hint: "OAuth", + choices: ["chutes"], + }, { value: "mistral", label: "Mistral AI", @@ -106,7 +106,7 @@ const AUTH_CHOICE_GROUP_DEFS: { { value: "zai", label: "Z.AI", - hint: "GLM Coding Plan / Global / CN", + hint: "API key · GLM Coding Plan / Global / CN", choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"], }, { @@ -124,7 +124,7 @@ const AUTH_CHOICE_GROUP_DEFS: { { value: "copilot", label: "Copilot", - hint: "GitHub + local proxy", + hint: "OAuth (GitHub device flow) or local proxy", choices: ["github-copilot", "copilot-proxy"], }, { @@ -148,7 +148,7 @@ const AUTH_CHOICE_GROUP_DEFS: { { value: "synthetic", label: "Synthetic", - hint: "Anthropic-compatible (multi-model)", + hint: "API key · Anthropic-compatible (multi-model)", choices: ["synthetic-api-key"], }, { @@ -166,13 +166,13 @@ const AUTH_CHOICE_GROUP_DEFS: { { value: "venice", label: "Venice AI", - hint: "Privacy-focused (uncensored models)", + hint: "API key · privacy-focused", choices: ["venice-api-key"], }, { value: "litellm", label: "LiteLLM", - hint: "Unified LLM gateway (100+ providers)", + hint: "API key · 100+ providers", choices: ["litellm-api-key"], }, { diff --git a/src/commands/auth-choice-prompt.ts b/src/commands/auth-choice-prompt.ts index 83c2e44eb96..cd01bedf6bc 100644 --- a/src/commands/auth-choice-prompt.ts +++ b/src/commands/auth-choice-prompt.ts @@ -28,7 +28,7 @@ export async function promptAuthChoiceGrouped(params: { ]; const providerSelection = (await params.prompter.select({ - message: "Model/auth provider", + message: "Choose how you want to connect.", options: providerOptions, })) as string; diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts index 1ef474682af..78d318976e1 100644 --- a/src/commands/auth-choice.apply.github-copilot.ts +++ b/src/commands/auth-choice.apply.github-copilot.ts @@ -42,7 +42,7 @@ export async function applyAuthChoiceGitHubCopilot( }); if (params.setDefaultModel) { - const model = "github-copilot/gpt-4o"; + const model = "github-copilot/gpt-5.4"; nextConfig = { ...nextConfig, agents: { diff --git a/src/commands/openai-model-default.ts b/src/commands/openai-model-default.ts index 191756e0fa0..519c2008d50 100644 --- a/src/commands/openai-model-default.ts +++ b/src/commands/openai-model-default.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { ensureModelAllowlistEntry } from "./model-allowlist.js"; -export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex"; +export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.4"; export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const next = ensureModelAllowlistEntry({ diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index e8265efd49e..bcf79ada092 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -122,7 +122,7 @@ export async function runOnboardingWizard( let flow: WizardFlow = explicitFlow ?? (await prompter.select({ - message: "Onboarding mode", + message: "Setup mode", options: [ { value: "quickstart", label: "QuickStart", hint: quickstartHint }, { value: "advanced", label: "Manual", hint: manualHint },