diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 9aff4b36bcc..f12f2890a2a 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -379,6 +379,9 @@ jobs: -derivedDataPath "$IOS_DERIVED_DATA" ) if [ -n "${TEST_FILTER:-}" ]; then + if [[ "$TEST_FILTER" == cmuxUITests/* ]]; then + XCODEBUILD_ARGS+=(-testPlan cmux-ui) + fi XCODEBUILD_ARGS+=(-only-testing:"$TEST_FILTER") else # Full UI tests currently exceed the pull-request simulator budget diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift index 4ccc5da35e6..635adedc3b9 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift @@ -4,7 +4,7 @@ import Foundation private enum MobileTaskModelRefreshEvent: Sendable { case host(MobileTaskModelListResult?) - case backend([MobileTaskAgentModel]?) + case backend(MobileTaskModelListResult?) } private struct MobileTaskModelRequestContext { @@ -192,23 +192,70 @@ extension MobileShellComposite { let rawModels = object["models"] as? [[String: Any]] else { throw MobileShellConnectionError.invalidResponse } - var models: [MobileTaskAgentModel] = [] - models.reserveCapacity(rawModels.count) - var seenIDs: Set = [] - for rawModel in rawModels { + let discoveryError: MobileTaskModelListError? + if let rawError = object["error"] { + guard let rawError = rawError as? String, + let parsedError = MobileTaskModelListError(rawValue: rawError) else { + throw MobileShellConnectionError.invalidResponse + } + discoveryError = parsedError + } else { + discoveryError = nil + } + func parseModel(_ rawModel: [String: Any]) throws -> MobileTaskAgentModel { guard let id = rawModel["id"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let displayName = rawModel["display_name"] as? String, - !displayName.isEmpty, - seenIDs.insert(id).inserted else { + !displayName.isEmpty else { throw MobileShellConnectionError.invalidResponse } - models.append(MobileTaskAgentModel( + let rawEfforts = rawModel["efforts"] as? [[String: Any]] ?? [] + var seenEffortIDs: Set = [] + var efforts: [MobileTaskAgentEffort] = [] + efforts.reserveCapacity(rawEfforts.count) + for rawEffort in rawEfforts { + guard let effortID = rawEffort["id"] as? String, + !effortID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let effortDisplayName = rawEffort["display_name"] as? String, + !effortDisplayName.isEmpty, + seenEffortIDs.insert(effortID).inserted else { + throw MobileShellConnectionError.invalidResponse + } + efforts.append(MobileTaskAgentEffort( + id: effortID, + displayName: effortDisplayName, + description: rawEffort["description"] as? String + )) + } + return MobileTaskAgentModel( id: id, - displayName: displayName - )) + displayName: displayName, + efforts: efforts, + defaultEffortID: rawModel["default_effort_id"] as? String + ) + } + var models: [MobileTaskAgentModel] = [] + models.reserveCapacity(rawModels.count) + var seenIDs: Set = [] + for rawModel in rawModels { + let model = try parseModel(rawModel) + guard seenIDs.insert(model.id).inserted else { + throw MobileShellConnectionError.invalidResponse + } + models.append(model) } - return MobileTaskModelListResult(models: models, source: source) + let defaultModel: MobileTaskAgentModel? + if let rawDefaultModel = object["default_model"] as? [String: Any] { + defaultModel = try parseModel(rawDefaultModel) + } else { + defaultModel = nil + } + return MobileTaskModelListResult( + models: models, + source: source, + defaultModel: defaultModel, + error: discoveryError + ) } private func captureTaskModelRequestContext( @@ -303,12 +350,25 @@ extension MobileShellComposite { macDeviceID: String, instanceTag _: String? ) -> [MobileTaskAgentModel]? { + discoveredTaskModelResult( + provider: provider, + macDeviceID: macDeviceID, + instanceTag: nil + )?.models + } + + /// Returns the cached model list and implicit Default metadata together. + public func discoveredTaskModelResult( + provider: MobileTaskAgentProvider, + macDeviceID: String, + instanceTag _: String? + ) -> MobileTaskModelListResult? { taskModelCache[ MobileTaskModelCacheKey( macDeviceID: macDeviceID, provider: provider ) - ]?.result.models + ]?.result } /// Refreshes one provider from the selected Mac and the over-the-air @@ -334,11 +394,19 @@ extension MobileShellComposite { macDeviceID: macDeviceID, hostResultLoader: { [weak self] in guard let self else { return nil } - return try? await self.fetchTaskModels( - provider: provider, - macDeviceID: macDeviceID, - instanceTag: instanceTag - ) + do { + return try await self.fetchTaskModels( + provider: provider, + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) + } catch { + return MobileTaskModelListResult( + models: [], + source: .fallback, + error: .hostUnavailable + ) + } }, didUpdate: didUpdate ) @@ -355,12 +423,14 @@ extension MobileShellComposite { provider: provider ) let catalogClient = taskModelCatalogClient + var hostFailure: MobileTaskModelListResult? + var backendResult: MobileTaskModelListResult? await withTaskGroup(of: MobileTaskModelRefreshEvent.self) { group in group.addTask { .host(await hostResultLoader()) } group.addTask { - .backend(try? await catalogClient.models(for: provider)) + .backend(try? await catalogClient.result(for: provider)) } for await event in group { @@ -370,9 +440,23 @@ extension MobileShellComposite { } switch event { case .host(let result): - guard let result, - result.source == .discovered, - !result.models.isEmpty else { + guard let result else { + continue + } + if let error = result.error { + hostFailure = result + if let backendResult, backendResult.error == nil { + let visibleResult = resultWithError( + backendResult, + with: error + ) + self.cacheTaskModels(visibleResult, for: key) + didUpdate?(visibleResult) + } + continue + } + guard result.source == .discovered, + !result.models.isEmpty || result.defaultModel != nil else { continue } cacheTaskModels(result, for: key) @@ -380,22 +464,40 @@ extension MobileShellComposite { group.cancelAll() return case .backend(let models): - guard let models, - !models.isEmpty, + guard let result = models, + !result.models.isEmpty, taskModelCache[key]?.result.source != .discovered else { continue } - let result = MobileTaskModelListResult( - models: models, - source: .backend + let visibleResult = resultWithError( + result, + with: hostFailure?.error ) - cacheTaskModels(result, for: key) - didUpdate?(result) + backendResult = visibleResult + cacheTaskModels(visibleResult, for: key) + didUpdate?(visibleResult) } } + if let hostFailure, backendResult == nil { + cacheTaskModels(hostFailure, for: key) + didUpdate?(hostFailure) + } } } + private func resultWithError( + _ result: MobileTaskModelListResult, + with error: MobileTaskModelListError? + ) -> MobileTaskModelListResult { + guard let error else { return result } + return MobileTaskModelListResult( + models: result.models, + source: result.source, + defaultModel: result.defaultModel, + error: error + ) + } + /// Applies the source-priority policy through an injectable host result. /// Kept internal so package tests can prove that authoritative agent data /// performs zero backend requests and legacy host fallbacks do not leak in. @@ -410,22 +512,19 @@ extension MobileShellComposite { ) if let hostResult, hostResult.source == .discovered, - !hostResult.models.isEmpty { + !hostResult.models.isEmpty || hostResult.defaultModel != nil { guard !Task.isCancelled else { return } cacheTaskModels(hostResult, for: key) return } guard !Task.isCancelled, - let models = try? await taskModelCatalogClient.models(for: provider), - !models.isEmpty, + let result = try? await taskModelCatalogClient.result(for: provider), + !result.models.isEmpty, !Task.isCancelled else { return } - cacheTaskModels( - MobileTaskModelListResult(models: models, source: .backend), - for: key - ) + cacheTaskModels(result, for: key) } private func cacheTaskModels( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift index 914860ca6b2..97a3217ac67 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift @@ -51,7 +51,16 @@ public struct MobileTaskModelCatalogClient: Sendable { for provider: MobileTaskAgentProvider ) async throws -> [MobileTaskAgentModel] { let data = try await loader(endpoint) - return try Self.models(from: data, provider: provider) + return try Self.result(from: data, provider: provider).models + } + + /// Fetches one provider's latest backend models and its implicit Default + /// selection metadata. + public func result( + for provider: MobileTaskAgentProvider + ) async throws -> MobileTaskModelListResult { + let data = try await loader(endpoint) + return try Self.result(from: data, provider: provider) } /// Parses one provider from the versioned backend payload. @@ -59,6 +68,15 @@ public struct MobileTaskModelCatalogClient: Sendable { from data: Data, provider: MobileTaskAgentProvider ) throws -> [MobileTaskAgentModel] { + try result(from: data, provider: provider).models + } + + /// Parses one provider's models and resolves its Default selection to the + /// matching catalog model without adding that model to the Default row. + public static func result( + from data: Data, + provider: MobileTaskAgentProvider + ) throws -> MobileTaskModelListResult { let catalog = try JSONDecoder().decode(Catalog.self, from: data) guard catalog.schemaVersion == 1, let providerCatalog = catalog.providers[provider.rawValue] else { @@ -74,12 +92,42 @@ public struct MobileTaskModelCatalogClient: Sendable { guard !id.isEmpty, !label.isEmpty, seenIDs.insert(id).inserted else { continue } - models.append(MobileTaskAgentModel(id: id, displayName: label)) + var seenEffortIDs: Set = [] + let efforts = (model.efforts ?? []).compactMap { effort -> MobileTaskAgentEffort? in + let effortID = effort.value.trimmingCharacters(in: .whitespacesAndNewlines) + let effortLabel = effort.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !effortID.isEmpty, !effortLabel.isEmpty, + seenEffortIDs.insert(effortID).inserted else { return nil } + let description = effort.description? + .trimmingCharacters(in: .whitespacesAndNewlines) + return MobileTaskAgentEffort( + id: effortID, + displayName: effortLabel, + description: description.flatMap { $0.isEmpty ? nil : $0 } + ) + } + models.append(MobileTaskAgentModel( + id: id, + displayName: label, + efforts: efforts, + defaultEffortID: model.defaultEffort? + .trimmingCharacters(in: .whitespacesAndNewlines) + )) } guard !models.isEmpty else { throw MobileTaskModelCatalogError.invalidCatalog } - return models + let defaultModelID = providerCatalog.defaultModel?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + let defaultModel = models.first { + $0.id == defaultModelID + } + return MobileTaskModelListResult( + models: models, + source: .backend, + defaultModel: defaultModel + ) } private static let productionEndpoint = URL( @@ -92,12 +140,21 @@ public struct MobileTaskModelCatalogClient: Sendable { } private struct ProviderCatalog: Decodable { + let defaultModel: String? let models: [Model] } private struct Model: Decodable { let id: String let label: String + let efforts: [Effort]? + let defaultEffort: String? + } + + private struct Effort: Decodable { + let value: String + let label: String + let description: String? } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift index cfcbcbb493f..9ff520dc75a 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift @@ -389,8 +389,25 @@ actor RoutingHostRouter { for waiter in reachedWaiters { waiter.resume() } await withCheckedContinuation { taskModelListContinuation = $0 } } - let models = taskModelsByProvider[provider, default: []].map { model in - ["id": model.id, "display_name": model.displayName] + let models: [[String: Any]] = taskModelsByProvider[provider, default: []].map { model in + var object: [String: Any] = [ + "id": model.id, + "display_name": model.displayName, + "efforts": model.efforts.map { effort in + var effortObject: [String: Any] = [ + "id": effort.id, + "display_name": effort.displayName, + ] + if let description = effort.description { + effortObject["description"] = description + } + return effortObject + }, + ] + if let defaultEffortID = model.defaultEffortID { + object["default_effort_id"] = defaultEffortID + } + return object } return try? Self.resultFrame(id: id, result: [ "source": "discovered", diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift index 8c569a47e02..54c64fa47d2 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift @@ -47,6 +47,57 @@ struct MobileTaskModelCatalogClientTests { ]) } + @Test func parsesEffortsOnlyFromTheirExactModel() throws { + let data = Data(#"{"schemaVersion":1,"providers":{"codex":{"models":[{"id":"gpt-large","label":"GPT Large","efforts":[{"value":"medium","label":"Medium","description":"Balanced"},{"value":"high","label":"High"}],"defaultEffort":"medium"},{"id":"gpt-small","label":"GPT Small","efforts":[{"value":"low","label":"Low"}],"defaultEffort":"low"}]}}}"#.utf8) + + let models = try MobileTaskModelCatalogClient.models( + from: data, + provider: .codex + ) + + #expect(models == [ + MobileTaskAgentModel( + id: "gpt-large", + displayName: "GPT Large", + efforts: [ + MobileTaskAgentEffort( + id: "medium", + displayName: "Medium", + description: "Balanced" + ), + MobileTaskAgentEffort(id: "high", displayName: "High"), + ], + defaultEffortID: "medium" + ), + MobileTaskAgentModel( + id: "gpt-small", + displayName: "GPT Small", + efforts: [MobileTaskAgentEffort(id: "low", displayName: "Low")], + defaultEffortID: "low" + ), + ]) + } + + @Test func resolvesProviderDefaultModelEffortsWithoutInventingAPickerModel() throws { + let data = Data(#"{"schemaVersion":1,"providers":{"claude":{"defaultModel":"claude-default","models":[{"id":"claude-default","label":"Claude Default","efforts":[{"value":"medium","label":"Medium"},{"value":"high","label":"High"}],"defaultEffort":"medium"},{"id":"claude-other","label":"Claude Other","efforts":[{"value":"low","label":"Low"}],"defaultEffort":"low"}]}}}"#.utf8) + + let result = try MobileTaskModelCatalogClient.result( + from: data, + provider: .claude + ) + + #expect(result.models.map(\.id) == ["claude-default", "claude-other"]) + #expect(result.defaultModel == MobileTaskAgentModel( + id: "claude-default", + displayName: "Claude Default", + efforts: [ + MobileTaskAgentEffort(id: "medium", displayName: "Medium"), + MobileTaskAgentEffort(id: "high", displayName: "High"), + ], + defaultEffortID: "medium" + )) + } + @Test func sameInstalledClientObservesModelsReleasedAfterFirstRefresh() async throws { let probe = MobileTaskModelCatalogProbe(responses: [ catalogData(claude: [("backend-next-999", "Backend Next 999")]), @@ -114,7 +165,15 @@ struct MobileTaskModelCatalogClientTests { [ MobileTaskAgentModel( id: "host-next-999", - displayName: "Host Next 999" + displayName: "Host Next 999", + efforts: [ + MobileTaskAgentEffort( + id: "high", + displayName: "High", + description: "More reasoning" + ), + ], + defaultEffortID: "high" ), ], provider: .claude @@ -139,7 +198,15 @@ struct MobileTaskModelCatalogClientTests { ) == [ MobileTaskAgentModel( id: "host-next-999", - displayName: "Host Next 999" + displayName: "Host Next 999", + efforts: [ + MobileTaskAgentEffort( + id: "high", + displayName: "High", + description: "More reasoning" + ), + ], + defaultEffortID: "high" ), ]) #expect(store.taskModelListSource( diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift index 80cfabf150b..0d6686dd123 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift @@ -55,10 +55,46 @@ public enum MobileTaskAgentProvider: String, CaseIterable, Sendable { /// - command: User-authored task-template command. /// - Returns: The command running the selected model. public func command(applying modelID: String, to command: String) -> String { + applyingOption( + applyingOptionValue: modelID, + flagSpellings: modelFlagSpellings, + to: command + ) + } + + /// Applies one model-specific effort selection to the command. + /// + /// The effort value is supplied by the selected model's live catalog. + /// This type owns only each provider's CLI spelling and never invents a + /// provider-wide set of possible values. + public func command(applyingEffort effortID: String, to command: String) -> String { + switch self { + case .claude: + applyingOption( + applyingOptionValue: effortID, + flagSpellings: ["--effort"], + to: command + ) + case .codex: + applyingCodexEffort(effortID, to: command) + case .openCode: + applyingOption( + applyingOptionValue: effortID, + flagSpellings: ["--variant"], + to: command + ) + } + } + + private func applyingOption( + applyingOptionValue optionValue: String, + flagSpellings: [String], + to command: String + ) -> String { guard let firstToken = Self.tokenRange(in: command, from: command.startIndex) else { return command } - let quotedID = "'\(modelID.replacingOccurrences(of: "'", with: "'\\''"))'" + let quotedValue = Self.shellQuoted(optionValue) // Collect edits against the immutable command, then apply back to // front so earlier ranges stay valid. @@ -79,10 +115,10 @@ public enum MobileTaskAgentProvider: String, CaseIterable, Sendable { // syntax that must be processed so the stale value cannot win. let boundary = Self.unquotedCommandBoundaryIndex(in: text) let word = boundary.map { text[..<$0] } ?? text - if modelFlagSpellings.contains(where: { word == $0 }) { + if flagSpellings.contains(where: { word == $0 }) { if let boundary { // `--model;`: supply the value before the separator. - edits.append((boundary.. String { + guard let firstToken = Self.tokenRange(in: command, from: command.startIndex) else { + return command + } + let replacement = "model_reasoning_effort=\(Self.shellQuoted(effortID))" + var edits: [(range: Range, replacement: String)] = [] + var searchStart = firstToken.upperBound + while let token = Self.tokenRange(in: command, from: searchStart) { + if command[searchStart.. String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" } /// Model-flag spellings this provider's CLI accepts; the first is used @@ -224,13 +305,42 @@ public struct MobileTaskAgentModel: Equatable, Sendable, Identifiable { public let id: String /// Product name displayed verbatim in the composer. public let displayName: String + /// Effort values reported for this exact model, in provider order. + public let efforts: [MobileTaskAgentEffort] + /// Provider-reported default effort, when it names one of `efforts`. + public let defaultEffortID: String? /// Creates a selectable coding-agent model. /// - Parameters: /// - id: CLI identifier passed to the provider's model flag. /// - displayName: Product name displayed verbatim in the composer. - public init(id: String, displayName: String) { + public init( + id: String, + displayName: String, + efforts: [MobileTaskAgentEffort] = [], + defaultEffortID: String? = nil + ) { + self.id = id + self.displayName = displayName + self.efforts = efforts + self.defaultEffortID = efforts.contains { $0.id == defaultEffortID } + ? defaultEffortID + : nil + } +} + +/// One effort choice reported for one exact coding-agent model. +public struct MobileTaskAgentEffort: Equatable, Sendable, Identifiable { + /// CLI value passed to the provider's effort or variant flag. + public let id: String + /// Product name displayed verbatim in the composer. + public let displayName: String + /// Optional provider-authored explanation of the tradeoff. + public let description: String? + + public init(id: String, displayName: String, description: String? = nil) { self.id = id self.displayName = displayName + self.description = description } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift index 18c492d5aca..123d43881d9 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift @@ -15,12 +15,14 @@ public struct MobileTaskCommandComposer: Sendable { /// - template: The selected task template. /// - prompt: User-entered task prompt. /// - modelID: Optional CLI model identifier to apply to a known provider. + /// - effortID: Optional effort reported by the selected exact model. /// - attachmentPaths: Absolute Mac paths produced by attachment uploads. /// - Returns: The command, environment, and prompt-derived title. public func compose( template: MobileTaskTemplate, prompt rawPrompt: String, modelID: String? = nil, + effortID: String? = nil, attachmentPaths: [String] = [] ) -> MobileTaskComposition { let prompt = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines) @@ -29,8 +31,13 @@ public struct MobileTaskCommandComposer: Sendable { return MobileTaskComposition(initialCommand: nil, initialEnv: [:], title: title) } let initialCommand: String - if let modelID, let provider = MobileTaskAgentProvider(command: template.command) { - initialCommand = provider.command(applying: modelID, to: template.command) + if let provider = MobileTaskAgentProvider(command: template.command) { + let commandWithModel = modelID.map { + provider.command(applying: $0, to: template.command) + } ?? template.command + initialCommand = effortID.map { + provider.command(applyingEffort: $0, to: commandWithModel) + } ?? commandWithModel } else { initialCommand = template.command } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift index d17d2c4ab3b..c1cc006b054 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift @@ -6,6 +6,8 @@ public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { public var prompt: String /// Optional CLI model identifier selected for the task template. public var modelID: String? + /// Optional effort selected from the exact model's reported catalog. + public var effortID: String? /// Selected template, validated against current templates when restored. public var templateID: MobileTaskTemplate.ID? /// Selected Mac, validated against current paired Macs when restored. @@ -32,6 +34,7 @@ public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { public init( prompt: String, modelID: String? = nil, + effortID: String? = nil, templateID: MobileTaskTemplate.ID?, macDeviceID: String?, macInstanceTag: String? = nil, @@ -44,6 +47,7 @@ public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { ) { self.prompt = prompt self.modelID = modelID + self.effortID = effortID self.templateID = templateID self.macDeviceID = macDeviceID self.macInstanceTag = macInstanceTag diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift index 7bd72ddda9a..4ab53860441 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift @@ -4,6 +4,8 @@ public struct MobileTaskModelAvailability: Sendable { public let provider: MobileTaskAgentProvider? /// Models shown by the composer. public let models: [MobileTaskAgentModel] + /// Metadata for the provider's implicit Default selection. + public let defaultModel: MobileTaskAgentModel? /// Resolves only runtime-supplied models. /// @@ -12,12 +14,14 @@ public struct MobileTaskModelAvailability: Sendable { /// - discoveredModels: Cached models returned by the selected Mac. public init( template: MobileTaskTemplate?, - discoveredModels: [MobileTaskAgentModel]? + discoveredModels: [MobileTaskAgentModel]?, + defaultModel: MobileTaskAgentModel? = nil ) { provider = template.flatMap { MobileTaskAgentProvider(command: $0.command) } models = discoveredModels ?? [] + self.defaultModel = defaultModel } /// Validates an identifier against the same list the composer displays. diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift index 6b64b55e7bc..429f2905520 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift @@ -1,20 +1,42 @@ +/// Why a provider model catalog could not be read. +public enum MobileTaskModelListError: String, Equatable, Sendable { + /// The provider executable is not available in the Mac user's login PATH. + case providerUnavailable = "provider_unavailable" + /// The provider was present, but its catalog command failed or returned no usable values. + case queryFailed = "query_failed" + /// The selected Mac could not answer the catalog request. + case hostUnavailable = "host_unavailable" +} + /// Models returned by the Mac and the strategy that produced them. public struct MobileTaskModelListResult: Equatable, Sendable { /// Models in composer display order. public let models: [MobileTaskAgentModel] + /// Metadata for the provider's implicit Default selection. This is kept + /// separate from `models` because Default must not become an explicit + /// model argument in a task command. + public let defaultModel: MobileTaskAgentModel? /// Whether the list was discovered, supplied by the backend, or unavailable. public let source: MobileTaskModelListSource + /// A provider or host discovery error, when applicable. + public let error: MobileTaskModelListError? /// Creates a task model list result. /// /// - Parameters: /// - models: Models in composer display order. /// - source: Strategy that produced the list. + /// - defaultModel: Metadata for the implicit Default selection. + /// - error: Provider or host discovery failure, when applicable. public init( models: [MobileTaskAgentModel], - source: MobileTaskModelListSource + source: MobileTaskModelListSource, + defaultModel: MobileTaskAgentModel? = nil, + error: MobileTaskModelListError? = nil ) { self.models = models self.source = source + self.defaultModel = defaultModel + self.error = error } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift index 340d08a28d7..8ecdb47bfda 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift @@ -17,6 +17,8 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { public let prompt: String /// Optional CLI model identifier captured from the composer. public let modelID: String? + /// Optional model-specific effort captured from the composer. + public let effortID: String? /// Optional workspace name exactly as entered in the composer. public let workspaceName: String /// Workspace name with surrounding whitespace removed. @@ -47,6 +49,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { /// - template: Task template selected when submission begins. /// - prompt: Prompt text to compose into the template command. /// - modelID: Optional CLI model identifier to apply to the command. + /// - effortID: Optional effort reported by the selected exact model. /// - macDeviceID: Identifier of the Mac that should create the task. /// - macInstanceTag: Exact paired app instance to target, or `nil`. /// - directory: Working-directory text shown in the composer. @@ -59,6 +62,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { template: MobileTaskTemplate, prompt: String, modelID: String? = nil, + effortID: String? = nil, macDeviceID: String, macInstanceTag: String? = nil, directory: String, @@ -73,6 +77,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { self.macInstanceTag = macInstanceTag self.prompt = prompt self.modelID = modelID + self.effortID = effortID self.workspaceName = workspaceName self.trimmedWorkspaceName = workspaceName.trimmingCharacters(in: .whitespacesAndNewlines) self.workspaceGroupID = workspaceGroupID @@ -84,7 +89,8 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { self.composition = MobileTaskCommandComposer().compose( template: template, prompt: prompt, - modelID: modelID + modelID: modelID, + effortID: effortID ) } @@ -115,6 +121,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { macInstanceTag: macInstanceTag, prompt: prompt, modelID: modelID, + effortID: effortID, workspaceName: workspaceName, workspaceGroupID: workspaceGroupID, directory: directory, @@ -171,6 +178,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { MobileTaskComposerDraft( prompt: prompt, modelID: modelID, + effortID: effortID, templateID: templateID, macDeviceID: macDeviceID.isEmpty ? nil : macDeviceID, macInstanceTag: macDeviceID.isEmpty ? nil : macInstanceTag, @@ -188,6 +196,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { macInstanceTag: String?, prompt: String, modelID: String?, + effortID: String?, workspaceName: String, workspaceGroupID: MobileWorkspaceGroupPreview.ID?, directory: String, @@ -203,6 +212,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { self.macInstanceTag = macInstanceTag self.prompt = prompt self.modelID = modelID + self.effortID = effortID self.workspaceName = workspaceName self.trimmedWorkspaceName = trimmedWorkspaceName self.workspaceGroupID = workspaceGroupID diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift index bac410bca21..7608bea32a2 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift @@ -98,6 +98,79 @@ import Testing } } + @Test func modelSpecificEffortUsesEachProvidersNativeFlag() { + let seeds = MobileTaskTemplate.seedDefaults( + claudeName: "Claude", + codexName: "Codex", + openCodeName: "OpenCode", + shellName: "Shell" + ) + let cases = [ + (seeds[0], "claude-opus", "high", "claude --effort 'high' --model 'claude-opus' -- \"$CMUX_TASK_PROMPT\""), + (seeds[1], "gpt-5.6", "medium", "codex -c model_reasoning_effort='medium' -m 'gpt-5.6' -- \"$CMUX_TASK_PROMPT\""), + (seeds[2], "openai/gpt-5.6", "low", "opencode --variant 'low' --model 'openai/gpt-5.6' --prompt \"$CMUX_TASK_PROMPT\""), + ] + + for (template, modelID, effortID, expectedCommand) in cases { + let result = composer.compose( + template: template, + prompt: "Fix the race", + modelID: modelID, + effortID: effortID + ) + #expect(result.initialCommand == expectedCommand) + } + } + + @Test func defaultModelEffortLeavesModelUnpinned() { + let seeds = MobileTaskTemplate.seedDefaults( + claudeName: "Claude", + codexName: "Codex", + openCodeName: "OpenCode", + shellName: "Shell" + ) + let cases = [ + (seeds[0], "claude --effort 'high' -- \"$CMUX_TASK_PROMPT\""), + (seeds[1], "codex -c model_reasoning_effort='high' -- \"$CMUX_TASK_PROMPT\""), + (seeds[2], "opencode --variant 'high' --prompt \"$CMUX_TASK_PROMPT\""), + ] + + for (template, expectedCommand) in cases { + let result = composer.compose( + template: template, + prompt: "Fix the race", + modelID: nil, + effortID: "high" + ) + #expect(result.initialCommand == expectedCommand) + } + } + + @Test func selectedEffortReplacesEveryStaleProviderValue() { + let cases = [ + ( + MobileTaskTemplate(name: "Claude", icon: "agent:claude", command: "claude --effort low --effort=medium"), + "claude --effort 'high' --effort='high'" + ), + ( + MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex -c model_reasoning_effort='low' --config=model_reasoning_effort=medium"), + "codex -c model_reasoning_effort='high' --config=model_reasoning_effort='high'" + ), + ( + MobileTaskTemplate(name: "OpenCode", icon: "agent:opencode", command: "opencode --variant low --variant=medium"), + "opencode --variant 'high' --variant='high'" + ), + ] + + for (template, expectedCommand) in cases { + #expect(composer.compose( + template: template, + prompt: "Fix", + effortID: "high" + ).initialCommand == expectedCommand) + } + } + @Test func agentDiscoveredIdentifierFlowsToCommandWithoutDeviceCatalogKnowledge() { let template = MobileTaskTemplate( name: "Claude", diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift index 1a09fdca69a..d9b5153c054 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift @@ -73,6 +73,7 @@ import Testing let draft = MobileTaskComposerDraft( prompt: "Use the selected model", modelID: "claude-opus-4-8", + effortID: "high", templateID: UUID(), macDeviceID: "mac-a", directory: "~/Dev/cmux", @@ -86,6 +87,7 @@ import Testing #expect(restored == draft) #expect(restored.modelID == "claude-opus-4-8") + #expect(restored.effortID == "high") } @Test func legacyDraftWithoutWorkspaceNameStillDecodes() throws { @@ -105,6 +107,7 @@ import Testing #expect(restored.workspaceName == nil) #expect(restored.modelID == nil) + #expect(restored.effortID == nil) #expect(restored.prompt == "Keep this draft") } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerEffortMenuContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerEffortMenuContent.swift new file mode 100644 index 00000000000..3546d574c36 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerEffortMenuContent.swift @@ -0,0 +1,83 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI +import UIKit + +/// A transparent deferred UIKit menu over the native effort pill. +struct TaskComposerEffortMenuContent: UIViewRepresentable { + let efforts: [MobileTaskAgentEffort] + let selectedEffortID: String? + let selectedEffortName: String + let isEnabled: Bool + let selectEffort: (MobileTaskAgentEffort?) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UIButton { + let button = UIButton(type: .custom) + button.backgroundColor = .clear + button.showsMenuAsPrimaryAction = true + update(button, coordinator: context.coordinator) + button.menu = context.coordinator.menu + return button + } + + func updateUIView(_ button: UIButton, context: Context) { + update(button, coordinator: context.coordinator) + } + + private func update(_ button: UIButton, coordinator: Coordinator) { + coordinator.efforts = efforts + coordinator.selectedEffortID = selectedEffortID + coordinator.selectEffort = selectEffort + + button.isEnabled = isEnabled + button.isAccessibilityElement = true + button.accessibilityTraits = .button + if !isEnabled { + button.accessibilityTraits.insert(.notEnabled) + } + button.accessibilityIdentifier = "MobileTaskComposerEffortPill" + button.accessibilityLabel = L10n.string( + "mobile.taskComposer.effort", + defaultValue: "Effort" + ) + button.accessibilityValue = selectedEffortName + button.accessibilityHint = L10n.string( + "mobile.taskComposer.effort.accessibilityHint", + defaultValue: "Chooses the effort supported by the selected model." + ) + } + + @MainActor + final class Coordinator { + var efforts: [MobileTaskAgentEffort] = [] + var selectedEffortID: String? + var selectEffort: (MobileTaskAgentEffort?) -> Void = { _ in } + + lazy var menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + let efforts = self.efforts + let selectedEffortID = self.selectedEffortID + let actions = efforts.map { effort in + UIAction( + title: effort.displayName, + subtitle: effort.description, + state: effort.id == selectedEffortID ? .on : .off + ) { [weak self] _ in + self?.selectEffort(effort) + } + } + completion([UIMenu(options: .displayInline, children: actions)]) + }, + ]) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift index 7b911dc60cc..c9e3177e27f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift @@ -6,6 +6,7 @@ import UIKit /// A full-screen prompt canvas with compact task controls above the keyboard. struct TaskComposerLayout: View { + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @Binding var prompt: String let genericPromptPlaceholder: String let workspaceName: String @@ -16,6 +17,11 @@ struct TaskComposerLayout: View { let selectedTemplateID: MobileTaskTemplate.ID? let models: [MobileTaskAgentModel] let selectedModelID: String? + let efforts: [MobileTaskAgentEffort] + let selectedEffortID: String? + let modelErrorText: String? + let effortErrorText: String? + let agentErrorText: String? let isModelLoading: Bool let isSubmitting: Bool let isSubmitEnabled: Bool @@ -31,6 +37,7 @@ struct TaskComposerLayout: View { let endEditing: () -> Void let selectTemplate: (MobileTaskTemplate.ID) -> Void let selectModel: (MobileTaskAgentModel?) -> Void + let selectEffort: (MobileTaskAgentEffort?) -> Void let editTemplates: () -> Void let cancel: () -> Void let submit: () -> Void @@ -138,27 +145,32 @@ struct TaskComposerLayout: View { ScrollView(.horizontal) { HStack(spacing: 8) { agentPill - // Keep the provider readable before compressing - // the model label on compact rows. - .layoutPriority(1) + .fixedSize(horizontal: true, vertical: false) - if !models.isEmpty { - modelPill - } else if isModelLoading { + if isModelLoading { modelLoadingPill + .fixedSize(horizontal: true, vertical: false) + .transition(modelLoadingTransition) + } else if !models.isEmpty || !efforts.isEmpty || modelErrorText != nil { + if models.isEmpty, modelErrorText != nil { + modelErrorPill + .fixedSize(horizontal: true, vertical: false) + } else { + modelPill + .fixedSize(horizontal: true, vertical: false) + } + + if !efforts.isEmpty || effortErrorText != nil { + effortPill + .fixedSize(horizontal: true, vertical: false) + } } } - // Give the pills the viewport's finite width so their - // one-line labels compress inside their own capsules. - // Without this, ScrollView proposes infinite width and a - // long selected model extends beneath the fixed submit - // control before clipping at the viewport edge. - .containerRelativeFrame(.horizontal, alignment: .leading) } .scrollIndicators(.hidden) - // The pills are the row's only compressible region. A zero - // minimum lets the fixed 44pt edge controls claim their space - // before this viewport receives the remaining width. + // The shared picker strip owns overflow. Pills keep their + // readable intrinsic widths while the fixed edge controls + // claim their space outside this clipped viewport. .frame(minWidth: 0, maxWidth: .infinity) .layoutPriority(0) .clipped() @@ -234,7 +246,7 @@ struct TaskComposerLayout: View { TaskTemplateIcon(value: selectedTemplate.icon, size: 16) .frame(width: 18, height: 18) - Text(agentPillTitle(for: selectedTemplate)) + Text(agentErrorText ?? agentPillTitle(for: selectedTemplate)) .lineLimit(1) } else { Image(systemName: "person.crop.circle.badge.exclamationmark") @@ -253,7 +265,7 @@ struct TaskComposerLayout: View { .accessibilityHidden(true) } .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) + .foregroundStyle(agentErrorText == nil ? Color.primary : Color.red) .padding(.horizontal, 12) .frame(minHeight: 38) .background(Color.primary.opacity(0.07), in: Capsule()) @@ -263,13 +275,13 @@ struct TaskComposerLayout: View { .tint(Color.primary) .disabled(isDisabled) .accessibilityLabel(L10n.string("mobile.taskComposer.agent", defaultValue: "Agent")) - .accessibilityValue(selectedTemplate?.name ?? "") + .accessibilityValue(agentErrorText ?? selectedTemplate?.name ?? "") .accessibilityHint(TaskComposerSheet.templateAccessibilityHint) .accessibilityIdentifier("MobileTaskComposerAgentPill") // Recreate the whole Menu when the title changes: the UIKit menu // button otherwise animates its frame to the new width on iOS 26 and // clips the label against the stale bounds until it settles. - .id(selectedTemplate.map(agentPillTitle(for:))) + .id((selectedTemplate.map(agentPillTitle(for:)) ?? "") + (agentErrorText ?? "")) } private var modelPill: some View { @@ -278,7 +290,7 @@ struct TaskComposerLayout: View { .font(.caption.weight(.semibold)) .accessibilityHidden(true) - Text(selectedModelName) + Text(modelErrorText ?? selectedModelName) .lineLimit(1) Image(systemName: "chevron.down") @@ -287,7 +299,7 @@ struct TaskComposerLayout: View { .accessibilityHidden(true) } .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) + .foregroundStyle(modelErrorText == nil ? Color.primary : Color.red) .padding(.horizontal, 12) .frame(minHeight: 38) .background(Color.primary.opacity(0.07), in: Capsule()) @@ -306,7 +318,33 @@ struct TaskComposerLayout: View { } // See agentPill: identity-swap the pill so the new title cannot be // clipped by the old frame mid-animation. - .id(selectedModelName) + .id((modelErrorText ?? "") + selectedModelName) + } + + private var modelErrorPill: some View { + HStack(spacing: 7) { + Image(systemName: "exclamationmark.triangle") + .font(.caption.weight(.semibold)) + .accessibilityHidden(true) + + Text(modelErrorText ?? L10n.string( + "mobile.taskComposer.model.error.queryFailed", + defaultValue: "Couldn’t load models" + )) + .lineLimit(1) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.red) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(Color.red.opacity(0.12), in: Capsule()) + .frame(minHeight: 44) + .accessibilityElement(children: .ignore) + .accessibilityLabel(modelErrorText ?? L10n.string( + "mobile.taskComposer.model.error.queryFailed", + defaultValue: "Couldn’t load models" + )) + .accessibilityIdentifier("MobileTaskComposerModelErrorPill") } private var modelLoadingPill: some View { @@ -338,6 +376,55 @@ struct TaskComposerLayout: View { .accessibilityIdentifier("MobileTaskComposerModelLoadingPill") } + private var modelLoadingTransition: AnyTransition { + guard !accessibilityReduceMotion else { return .identity } + return .asymmetric( + insertion: .opacity + .combined(with: .scale(scale: 0.96)), + removal: .opacity + .combined(with: .scale(scale: 0.98)) + ) + } + + private var effortPill: some View { + HStack(spacing: 7) { + Image(systemName: "gauge.with.dots.needle.33percent") + .font(.caption.weight(.semibold)) + .accessibilityHidden(true) + + Text(effortErrorText ?? selectedEffortName) + .lineLimit(1) + + Image(systemName: "chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .font(.caption.weight(.semibold)) + .foregroundStyle( + effortErrorText != nil + ? Color.red + : (efforts.isEmpty ? Color.secondary : Color.primary) + ) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(Color.primary.opacity(0.07), in: Capsule()) + .frame(minHeight: 44) + .contentShape(Rectangle()) + .accessibilityHidden(true) + .overlay { + TaskComposerEffortMenuContent( + efforts: efforts, + selectedEffortID: selectedEffortID, + selectedEffortName: selectedEffortName, + isEnabled: !isDisabled && !efforts.isEmpty, + selectEffort: selectEffort + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .id((effortErrorText ?? "") + selectedEffortName) + } + private var submitButton: some View { Button(action: submit) { Group { @@ -381,6 +468,17 @@ struct TaskComposerLayout: View { models.displayName(forSelected: selectedModelID) } + private var selectedEffortName: String { + guard let selectedEffortID, + let effort = efforts.first(where: { $0.id == selectedEffortID }) else { + return L10n.string( + "mobile.taskComposer.effort", + defaultValue: "Effort" + ) + } + return effort.displayName + } + private var navigationTitle: String { let trimmedWorkspaceName = workspaceName.trimmingCharacters( in: .whitespacesAndNewlines diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift index e9b90a87e0e..471939105c2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift @@ -10,6 +10,7 @@ extension TaskComposerSheet { selectedTemplateID = template.id selectedModelID = validatedModelID explicitlySelectedModel = nil + selectedEffortID = nil if template.isPlainShell { removeStagedAttachmentFiles() attachments.removeAll() @@ -34,6 +35,12 @@ extension TaskComposerSheet { ) } explicitlySelectedModel = nil + let effortModel = selectedModel ?? modelAvailability.defaultModel + selectedEffortID = effortModel.flatMap { model in + model.efforts.contains { $0.id == snapshot.effortID } + ? snapshot.effortID + : model.defaultEffortID + } selectedMacDeviceID = snapshot.macDeviceID selectedMacInstanceTag = snapshot.macInstanceTag selectedWorkspaceGroupID = snapshot.workspaceGroupID @@ -153,6 +160,7 @@ extension TaskComposerSheet { return MobileTaskComposerDraft( prompt: prompt, modelID: selectedModel?.id, + effortID: selectedEffort?.id, templateID: selectedTemplateID, macDeviceID: selectedMacDeviceID.isEmpty ? nil : selectedMacDeviceID, macInstanceTag: selectedMacDeviceID.isEmpty ? nil : selectedMacInstanceTag, @@ -176,6 +184,7 @@ extension TaskComposerSheet { template: selectedTemplate, prompt: prompt, modelID: selectedModel?.id, + effortID: selectedEffort?.id, macDeviceID: selectedMacDeviceID, macInstanceTag: selectedMacInstanceTag, directory: directory, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift index 55ab518e8e4..d300d6c333c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift @@ -1,8 +1,62 @@ #if os(iOS) import CMUXMobileCore import CmuxMobileShellModel +import CmuxMobileSupport extension TaskComposerSheet { + var modelPickerErrorText: String? { + guard let displayedModelError else { return nil } + switch displayedModelError { + case .providerUnavailable: + return L10n.string( + "mobile.taskComposer.model.error.providerUnavailable", + defaultValue: "Agent models unavailable" + ) + case .queryFailed: + return L10n.string( + "mobile.taskComposer.model.error.queryFailed", + defaultValue: "Couldn’t load models" + ) + case .hostUnavailable: + return L10n.string( + "mobile.taskComposer.model.error.hostUnavailable", + defaultValue: "Mac unavailable" + ) + } + } + + var agentPickerErrorText: String? { + guard displayedModelError == .providerUnavailable, + let provider = modelRefreshID.provider else { return nil } + switch provider { + case .claude: + return L10n.string( + "mobile.taskComposer.agent.error.claudeUnavailable", + defaultValue: "Claude unavailable" + ) + case .codex: + return L10n.string( + "mobile.taskComposer.agent.error.codexUnavailable", + defaultValue: "Codex unavailable" + ) + case .openCode: + return L10n.string( + "mobile.taskComposer.agent.error.openCodeUnavailable", + defaultValue: "OpenCode unavailable" + ) + } + } + + var effortPickerErrorText: String? { + guard displayedModelError != nil, + !availableModels.isEmpty, + availableEfforts.isEmpty else { return nil } + return L10n.string( + "mobile.taskComposer.effort.error.queryFailed", + defaultValue: "Couldn’t load efforts" + ) + } + var modelAvailability: MobileTaskModelAvailability { guard let selectedTemplate, MobileTaskAgentProvider( @@ -10,12 +64,14 @@ extension TaskComposerSheet { ) != nil else { return MobileTaskModelAvailability( template: selectedTemplate, - discoveredModels: nil + discoveredModels: nil, + defaultModel: nil ) } return MobileTaskModelAvailability( template: selectedTemplate, - discoveredModels: displayedModels + discoveredModels: displayedModels, + defaultModel: displayedDefaultModel ) } @@ -45,6 +101,19 @@ extension TaskComposerSheet { return availableModels.first { $0.id == selectedModelID } } + var availableEfforts: [MobileTaskAgentEffort] { + selectedModel?.efforts ?? modelAvailability.defaultModel?.efforts ?? [] + } + + var effortDefaultModel: MobileTaskAgentModel? { + selectedModel ?? modelAvailability.defaultModel + } + + var selectedEffort: MobileTaskAgentEffort? { + guard let selectedEffortID else { return nil } + return availableEfforts.first { $0.id == selectedEffortID } + } + func validatedModelID( _ id: String?, for template: MobileTaskTemplate, @@ -79,11 +148,35 @@ extension TaskComposerSheet { updateSubmissionRequest(reconcileRecovery: true) { selectedModelID = selectedID explicitlySelectedModel = model + selectedEffortID = (model ?? modelAvailability.defaultModel)?.defaultEffortID } store.recordAppEvent( .taskModelSelected, correlationID: selectedID ) } + + func selectEffort(_ effort: MobileTaskAgentEffort?) { + guard !submissionPhase.disablesRequestEditing else { return } + let selectedID = effort?.id + guard availableEfforts.contains(where: { $0.id == selectedID }), + selectedEffortID != selectedID else { return } + updateSubmissionRequest(reconcileRecovery: true) { + selectedEffortID = selectedID + } + } + + func reconcileSelectedEffort() { + let reconciledID: String? + if availableEfforts.contains(where: { $0.id == selectedEffortID }) { + reconciledID = selectedEffortID + } else { + reconciledID = effortDefaultModel?.defaultEffortID + } + guard selectedEffortID != reconciledID else { return } + updateSubmissionRequest(reconcileRecovery: true) { + selectedEffortID = reconciledID + } + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift index 0fa3cd0d9cf..9374801af4f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift @@ -20,6 +20,7 @@ struct TaskComposerSheet: View { @State var selectedTemplateID: MobileTaskTemplate.ID? @State var selectedModelID: String? @State var explicitlySelectedModel: MobileTaskAgentModel? + @State var selectedEffortID: String? @State var selectedMacDeviceID: String @State var selectedMacInstanceTag: String? @State var selectedWorkspaceGroupID: MobileWorkspaceGroupPreview.ID? @@ -30,7 +31,10 @@ struct TaskComposerSheet: View { @State var workspaceGroupSelectionRequiresResolution = false @State private var modelRefreshTask: Task? @State private var modelRefreshOperationID: UUID? + @State private var isModelLoadingIndicatorVisible = false @State var displayedModels: [MobileTaskAgentModel] + @State var displayedDefaultModel: MobileTaskAgentModel? + @State var displayedModelError: MobileTaskModelListError? @State var directory: String @State var didEditDirectory = false @State var submissionPhase: TaskComposerSubmissionPhase = .idle @@ -57,6 +61,7 @@ struct TaskComposerSheet: View { private let restoredDraftAtInitialization: Bool private let availableMachines: [MobilePairedMac]? private let availableWorkspaceGroups: [MobileWorkspaceGroupPreview]? + private let modelLoadingIndicatorClock: any Clock let taskAttachmentsCapabilityOverride: Bool? let submitTaskComposer: @MainActor ( _ macDeviceID: String, @@ -82,6 +87,7 @@ struct TaskComposerSheet: View { availableWorkspaceGroups: [MobileWorkspaceGroupPreview]? = nil, taskAttachmentsCapabilityOverride: Bool? = nil, initialAttachments: [TaskComposerAttachment] = [], + modelLoadingIndicatorClock: any Clock = ContinuousClock(), submitTaskComposer: (@MainActor ( _ macDeviceID: String, _ instanceTag: String?, @@ -103,6 +109,7 @@ struct TaskComposerSheet: View { self.store = store self.availableMachines = availableMachines self.availableWorkspaceGroups = availableWorkspaceGroups + self.modelLoadingIndicatorClock = modelLoadingIndicatorClock self.taskAttachmentsCapabilityOverride = taskAttachmentsCapabilityOverride self.sessionGeneration = store.currentSessionGeneration self.searchTaskDirectories = searchTaskDirectories @@ -173,8 +180,8 @@ struct TaskComposerSheet: View { let initialProvider = selectedTemplate.flatMap { MobileTaskAgentProvider(command: $0.command) } - let initialDiscoveredModels = initialProvider.flatMap { - store.discoveredTaskModels( + let initialModelResult = initialProvider.flatMap { + store.discoveredTaskModelResult( provider: $0, macDeviceID: selectedMacID, instanceTag: selectedMac?.instanceTag @@ -182,7 +189,8 @@ struct TaskComposerSheet: View { } let initialModelAvailability = MobileTaskModelAvailability( template: selectedTemplate, - discoveredModels: initialDiscoveredModels + discoveredModels: initialModelResult?.models, + defaultModel: initialModelResult?.defaultModel ) // A model persisted by this composer was already validated when the // user selected it. Preserve that explicit choice across a cold cache @@ -195,6 +203,18 @@ struct TaskComposerSheet: View { restoredDraftModelID, previouslyValidModelID: restoredDraftModelID ) + let initialSelectedModel = initialModelAvailability.models.first { + $0.id == initialModelID + } + let restoredDraftEffortID = (draft?.modelID == initialModelID) + ? draft?.effortID + : nil + let initialEffortModel = initialSelectedModel ?? initialModelAvailability.defaultModel + let initialEffortID = initialEffortModel.flatMap { model in + model.efforts.contains { $0.id == restoredDraftEffortID } + ? restoredDraftEffortID + : model.defaultEffortID + } let openDirectory = Self.preferredOpenDirectory( workspaces: store.workspaces, selectedWorkspaceID: store.selectedWorkspaceID, @@ -217,12 +237,14 @@ struct TaskComposerSheet: View { // bytes, so its operation ID (and any recovery bound to it) must not // be reused for the resulting default-model command. let draftModelSurvivedValidation = draft?.modelID == nil || initialModelID != nil + let draftEffortSurvivedValidation = draft?.effortID == initialEffortID let restoredOperationID = ( draft?.templateID == selectedTemplateID && draft?.macDeviceID == (selectedMacID.isEmpty ? nil : selectedMacID) && draft?.workspaceGroupID == initialWorkspaceGroupID && canRestoreDraftDirectory && draftModelSurvivedValidation + && draftEffortSurvivedValidation ) ? draft?.operationID : nil let initialPrompt = draft?.prompt ?? "" let initialWorkspaceName = draft?.workspaceName ?? "" @@ -232,6 +254,7 @@ struct TaskComposerSheet: View { template: $0, prompt: initialPrompt, modelID: initialModelID, + effortID: initialEffortID, macDeviceID: selectedMacID, macInstanceTag: selectedMac?.instanceTag, directory: initialDirectory, @@ -246,6 +269,7 @@ struct TaskComposerSheet: View { && draft?.workspaceGroupID == initialWorkspaceGroupID && canRestoreDraftDirectory && draftModelSurvivedValidation + && draftEffortSurvivedValidation let initialCompletedOperationRecovery = (canRestoreCompletedOperation ? draft?.completedOperationID : nil) @@ -260,11 +284,14 @@ struct TaskComposerSheet: View { _explicitlySelectedModel = State(initialValue: initialModelAvailability.models.first { $0.id == initialModelID }) + _selectedEffortID = State(initialValue: initialEffortID) _selectedMacDeviceID = State(initialValue: selectedMacID) _selectedMacInstanceTag = State(initialValue: selectedMac?.instanceTag) _selectedWorkspaceGroupID = State(initialValue: initialWorkspaceGroupID) _pendingRestoredWorkspaceGroupID = State(initialValue: draft?.workspaceGroupID) - _displayedModels = State(initialValue: initialDiscoveredModels ?? []) + _displayedModels = State(initialValue: initialModelResult?.models ?? []) + _displayedDefaultModel = State(initialValue: initialModelResult?.defaultModel) + _displayedModelError = State(initialValue: initialModelResult?.error) _attachments = State(initialValue: initialAttachments) _directory = State(initialValue: initialDirectory) _didEditDirectory = State(initialValue: canRestoreDraftDirectory && draft?.didEditDirectory == true) @@ -400,6 +427,9 @@ struct TaskComposerSheet: View { .onChange(of: modelRefreshID, initial: true) { _, _ in restartModelRefresh() } + .task(id: isModelLoading) { + await updateModelLoadingIndicator(isLoading: isModelLoading) + } } private var composerLayout: some View { @@ -414,7 +444,12 @@ struct TaskComposerSheet: View { selectedTemplateID: selectedTemplateID, models: availableModels, selectedModelID: selectedModelID, - isModelLoading: isModelLoading, + efforts: availableEfforts, + selectedEffortID: selectedEffortID, + modelErrorText: modelPickerErrorText, + effortErrorText: effortPickerErrorText, + agentErrorText: agentPickerErrorText, + isModelLoading: isModelLoadingIndicatorVisible, isSubmitting: submissionPhase.showsProgress, isSubmitEnabled: selectedMachine != nil && canLaunchSelectedTemplate @@ -432,6 +467,7 @@ struct TaskComposerSheet: View { endEditing: resolveCompletedOperationRecoveryAfterEditing, selectTemplate: selectTemplateFromPicker, selectModel: selectModel, + selectEffort: selectEffort, editTemplates: presentTemplateEditor, cancel: cancelComposer, submit: startSubmission, @@ -595,7 +631,7 @@ struct TaskComposerSheet: View { ) } - private var modelRefreshID: TaskComposerModelRefreshID { + var modelRefreshID: TaskComposerModelRefreshID { TaskComposerModelRefreshID( provider: selectedTemplate.flatMap { MobileTaskAgentProvider(command: $0.command) @@ -612,12 +648,30 @@ struct TaskComposerSheet: View { displayedModels.isEmpty && modelRefreshOperationID != nil } + private func updateModelLoadingIndicator(isLoading: Bool) async { + guard isModelLoadingIndicatorVisible != isLoading else { return } + // task(id:) cancels this debounce when the fetch changes state. Fast + // responses never flash, while a visible pill gets a short exit dwell. + do { + try await modelLoadingIndicatorClock.sleep(for: .milliseconds(80)) + } catch { + return + } + guard !Task.isCancelled else { return } + withAnimation(accessibilityReduceMotion ? nil : .easeInOut(duration: 0.12)) { + isModelLoadingIndicatorVisible = isLoading + } + } + private func restartModelRefresh() { modelRefreshTask?.cancel() modelRefreshOperationID = nil guard let provider = modelRefreshID.provider, !selectedMacDeviceID.isEmpty else { displayedModels = [] + displayedDefaultModel = nil + displayedModelError = nil + reconcileSelectedEffort() modelRefreshTask = nil return } @@ -626,14 +680,17 @@ struct TaskComposerSheet: View { let refreshID = modelRefreshID let operationID = UUID() modelRefreshOperationID = operationID - let cachedModels = store.discoveredTaskModels( + let cachedResult = store.discoveredTaskModelResult( provider: provider, macDeviceID: macDeviceID, instanceTag: instanceTag - ) ?? [] + ) ?? MobileTaskModelListResult(models: [], source: .fallback) // Keep a usable cached catalog visible while the host and backend are // refreshed. An authoritative host result replaces it in place. - displayedModels = cachedModels + displayedModels = cachedResult.models + displayedDefaultModel = cachedResult.defaultModel + displayedModelError = cachedResult.error + reconcileSelectedEffort() modelRefreshTask = Task { await store.refreshTaskModels( provider: provider, @@ -644,16 +701,22 @@ struct TaskComposerSheet: View { modelRefreshOperationID == operationID, modelRefreshID == refreshID else { return } displayedModels = result.models + displayedDefaultModel = result.defaultModel + displayedModelError = result.error + reconcileSelectedEffort() } guard !Task.isCancelled, modelRefreshOperationID == operationID, modelRefreshID == refreshID else { return } - if let refreshedModels = store.discoveredTaskModels( + if let refreshedResult = store.discoveredTaskModelResult( provider: provider, macDeviceID: macDeviceID, instanceTag: instanceTag ) { - displayedModels = refreshedModels + displayedModels = refreshedResult.models + displayedDefaultModel = refreshedResult.defaultModel + displayedModelError = refreshedResult.error + reconcileSelectedEffort() } modelRefreshOperationID = nil modelRefreshTask = nil @@ -904,6 +967,7 @@ struct TaskComposerSheet: View { selectedTemplateID = template.id selectedModelID = nil explicitlySelectedModel = nil + selectedEffortID = nil syncSuggestedDirectory() } store.recordAppEvent( @@ -939,6 +1003,7 @@ struct TaskComposerSheet: View { self.selectedTemplateID = templates.first?.id } selectedModelID = selectedModel?.id + reconcileSelectedEffort() // Sync template edits unless the user typed the directory. syncSuggestedDirectory() } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModel.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModel.swift index 7b4cdc0e3db..89e6af8e435 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModel.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModel.swift @@ -4,14 +4,40 @@ public struct MobileTaskModel: Equatable, Sendable { public let id: String /// Product name displayed verbatim by the composer. public let displayName: String + /// Effort values reported for this exact model, in provider order. + public let efforts: [MobileTaskModelEffort] + /// Provider-reported default effort, when present in `efforts`. + public let defaultEffortID: String? /// Creates a discovered task model. /// /// - Parameters: /// - id: CLI identifier passed to the provider. /// - displayName: Product name displayed by the composer. - public init(id: String, displayName: String) { + public init( + id: String, + displayName: String, + efforts: [MobileTaskModelEffort] = [], + defaultEffortID: String? = nil + ) { self.id = id self.displayName = displayName + self.efforts = efforts + self.defaultEffortID = efforts.contains { $0.id == defaultEffortID } + ? defaultEffortID + : nil + } +} + +/// One effort choice reported for one exact discovered model. +public struct MobileTaskModelEffort: Equatable, Sendable { + public let id: String + public let displayName: String + public let description: String? + + public init(id: String, displayName: String, description: String? = nil) { + self.id = id + self.displayName = displayName + self.description = description } } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListResult.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListResult.swift index 2cfc03c70b7..1657770048f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListResult.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListResult.swift @@ -1,20 +1,40 @@ +/// Why a provider model catalog could not be read. +public enum MobileTaskModelListError: String, Equatable, Sendable { + /// The provider executable is not available in the Mac user's login PATH. + case providerUnavailable = "provider_unavailable" + /// The provider was present, but its catalog command failed or returned no usable values. + case queryFailed = "query_failed" +} + /// A task provider's models and the strategy that produced them. public struct MobileTaskModelListResult: Equatable, Sendable { /// Models in composer display order. public let models: [MobileTaskModel] + /// Metadata for the provider's implicit Default selection. This is kept + /// separate from `models` because Default must not become an explicit + /// model argument in a task command. + public let defaultModel: MobileTaskModel? /// Whether the list was discovered, supplied by the backend, or unavailable. public let source: MobileTaskModelListSource + /// A provider discovery error, when no authoritative values were available. + public let error: MobileTaskModelListError? /// Creates a task model list result. /// /// - Parameters: /// - models: Models in composer display order. /// - source: Strategy that produced the list. + /// - defaultModel: Metadata for the implicit Default selection. + /// - error: Provider discovery failure, when applicable. public init( models: [MobileTaskModel], - source: MobileTaskModelListSource + source: MobileTaskModelListSource, + defaultModel: MobileTaskModel? = nil, + error: MobileTaskModelListError? = nil ) { self.models = models self.source = source + self.defaultModel = defaultModel + self.error = error } } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelParser.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelParser.swift index be433ed7140..da3c65f172f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelParser.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelParser.swift @@ -5,17 +5,60 @@ public struct MobileTaskModelParser: Sendable { /// Creates a model parser. public init() {} - /// Parses nonblank OpenCode output lines as model identifiers. - /// - /// Duplicate identifiers are removed while preserving first-seen order. - /// - /// - Parameter output: Standard output from `opencode models`. - /// - Returns: Unique, nonblank model identifiers in output order. - public func openCodeModelIDs(from output: String) -> [String] { - uniqueNonemptyStrings( - output.split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - ) + /// Parses `opencode models --verbose`, where each provider-qualified ID is + /// followed by one pretty-printed JSON object. Variant keys are effort + /// choices for that exact model. + public func openCodeModels(from output: String) -> [MobileTaskModel] { + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + var models: [MobileTaskModel] = [] + var lineIndex = 0 + while lineIndex < lines.count { + let id = lines[lineIndex].trimmingCharacters(in: .whitespacesAndNewlines) + lineIndex += 1 + guard !id.isEmpty, lineIndex < lines.count, + lines[lineIndex].trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") + else { continue } + var objectLines: [Substring] = [] + var depth = 0 + var hasStarted = false + var isInString = false + var isEscaped = false + while lineIndex < lines.count { + let line = lines[lineIndex] + objectLines.append(line) + lineIndex += 1 + for character in line { + if isEscaped { + isEscaped = false + } else if character == "\\", isInString { + isEscaped = true + } else if character == "\"" { + isInString.toggle() + } else if !isInString, character == "{" { + depth += 1 + hasStarted = true + } else if !isInString, character == "}" { + depth -= 1 + } + } + if hasStarted, depth == 0 { break } + } + guard let data = objectLines.joined(separator: "\n").data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + let name = (object["name"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let variants = object["variants"] as? [String: Any] ?? [:] + let efforts = variants.keys + .sorted(by: Self.effortPrecedes) + .compactMap(Self.effort(from:)) + models.append(MobileTaskModel( + id: id, + displayName: name.flatMap { $0.isEmpty ? nil : $0 } ?? id, + efforts: efforts + )) + } + return uniqueModels(models) } /// Parses Claude Code's control-stream `list_models` response. @@ -23,6 +66,19 @@ public struct MobileTaskModelParser: Sendable { /// The synthetic `default` choice is omitted because the composer already /// represents provider-default behavior with no explicit model flag. public func claudeModels(from output: String) -> [MobileTaskModel] { + claudeModelList(from: output).models + } + + /// Parses the implicit provider-default choice from Claude Code's + /// control-stream catalog. Claude reports this as a synthetic `default` + /// row, which must remain metadata rather than an explicit picker item. + public func claudeDefaultModel(from output: String) -> MobileTaskModel? { + claudeModelList(from: output).defaultModel + } + + private func claudeModelList( + from output: String + ) -> (models: [MobileTaskModel], defaultModel: MobileTaskModel?) { for line in output.split(separator: "\n", omittingEmptySubsequences: true) { guard let data = String(line).data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) @@ -35,20 +91,30 @@ public struct MobileTaskModelParser: Sendable { let rawModels = payload["models"] as? [[String: Any]] else { continue } - return uniqueModels(rawModels.compactMap { raw in - guard let rawID = raw["value"] as? String else { return nil } - let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty, id.lowercased() != "default" else { return nil } - let rawName = raw["displayName"] as? String - let displayName = rawName? - .trimmingCharacters(in: .whitespacesAndNewlines) - return MobileTaskModel( - id: id, - displayName: displayName.flatMap { $0.isEmpty ? nil : $0 } ?? id - ) - }) + let parsedModels = rawModels.compactMap(Self.claudeModel(from:)) + let models = uniqueModels(parsedModels.filter { $0.id.lowercased() != "default" }) + let defaultModel = parsedModels.first { $0.id.lowercased() == "default" } + return (models, defaultModel) } - return [] + return ([], nil) + } + + private static func claudeModel(from raw: [String: Any]) -> MobileTaskModel? { + guard let rawID = raw["value"] as? String else { return nil } + let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { return nil } + let rawName = raw["displayName"] as? String + let displayName = rawName?.trimmingCharacters(in: .whitespacesAndNewlines) + let efforts = (raw["supportedEffortLevels"] as? [String] ?? []) + .compactMap(Self.effort(from:)) + let defaultEffortID = (raw["defaultEffortLevel"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return MobileTaskModel( + id: id, + displayName: displayName.flatMap { $0.isEmpty ? nil : $0 } ?? id, + efforts: efforts, + defaultEffortID: defaultEffortID + ) } /// Parses Codex's model catalog JSON. @@ -75,9 +141,25 @@ public struct MobileTaskModelParser: Sendable { let rawName = raw["display_name"] as? String let displayName = rawName? .trimmingCharacters(in: .whitespacesAndNewlines) + let efforts = (raw["supported_reasoning_levels"] as? [[String: Any]] ?? []) + .compactMap { rawEffort -> MobileTaskModelEffort? in + guard let value = rawEffort["effort"] as? String, + let effort = Self.effort(from: value) else { return nil } + let description = (rawEffort["description"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return MobileTaskModelEffort( + id: effort.id, + displayName: effort.displayName, + description: description.flatMap { $0.isEmpty ? nil : $0 } + ) + } + let defaultEffortID = (raw["default_reasoning_level"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) return MobileTaskModel( id: id, - displayName: displayName.flatMap { $0.isEmpty ? nil : $0 } ?? id + displayName: displayName.flatMap { $0.isEmpty ? nil : $0 } ?? id, + efforts: efforts, + defaultEffortID: defaultEffortID ) }) } @@ -129,9 +211,38 @@ public struct MobileTaskModelParser: Sendable { return model.isEmpty ? nil : model } - private func uniqueNonemptyStrings(_ values: [String]) -> [String] { - var seen: Set = [] - return values.filter { !$0.isEmpty && seen.insert($0).inserted } + private static func effort(from rawValue: String) -> MobileTaskModelEffort? { + let id = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { return nil } + let words = id.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + return MobileTaskModelEffort(id: id, displayName: words.capitalized) + } + + /// Orders OpenCode's variant keys like the other providers' effort lists. + /// OpenCode emits variants as a JSON object, so dictionary iteration does + /// not carry the provider's intended low-to-max order into Swift. + private static func effortPrecedes(_ lhs: String, _ rhs: String) -> Bool { + let lhsRank = effortRank(lhs) + let rhsRank = effortRank(rhs) + guard lhsRank == rhsRank else { return lhsRank < rhsRank } + return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending + } + + private static func effortRank(_ rawValue: String) -> Int { + let normalized = rawValue + .lowercased() + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + switch normalized { + case "none": return 0 + case "low": return 1 + case "medium": return 2 + case "high": return 3 + case "xhigh": return 4 + case "max": return 5 + default: return 6 + } } private func uniqueModels(_ models: [MobileTaskModel]) -> [MobileTaskModel] { diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProviderStrategy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProviderStrategy.swift index eb02bc9da8f..a46aab4c6e7 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProviderStrategy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProviderStrategy.swift @@ -52,41 +52,100 @@ public struct MobileTaskModelProviderStrategy: Sendable { // OpenCode resolves installed provider authentication before it // prints the catalog. A cold invocation commonly exceeds five // seconds, while the result is cached above this strategy. - let output = await commandRunner("opencode models", .seconds(30)) - let ids = output.map(parser.openCodeModelIDs(from:)) ?? [] - return discovered(ids.map { - MobileTaskModel(id: $0, displayName: $0) - }) + let output = await commandRunner("opencode models --verbose", .seconds(30)) + let models = output.map(parser.openCodeModels(from:)) ?? [] + guard !models.isEmpty else { + return await failedDiscovery( + for: provider, + commandReturnedOutput: output != nil + ) + } + return discovered(models) case .codex: let output = await commandRunner(Self.codexModelsCommand, .seconds(5)) let discoveredModels = output.map(parser.codexModels(from:)) ?? [] if !discoveredModels.isEmpty { - return discovered(discoveredModels) + return discovered( + discoveredModels, + defaultModel: await codexDefaultModel(in: discoveredModels) + ) } let url = homeDirectory .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("models_cache.json") let models = await fileReader(url) .map(parser.codexModels(from:)) ?? [] - return discovered(models) + let defaultModel = await codexDefaultModel(in: models) + guard !models.isEmpty else { + return await failedDiscovery( + for: provider, + commandReturnedOutput: output != nil + ) + } + return discovered(models, defaultModel: defaultModel) case .claude: let output = await commandRunner( Self.claudeModelListCommand, .seconds(30) ) - return discovered(output.map(parser.claudeModels(from:)) ?? []) + let models = output.map(parser.claudeModels(from:)) ?? [] + let defaultModel = output.flatMap(parser.claudeDefaultModel(from:)) + guard !models.isEmpty || defaultModel != nil else { + return await failedDiscovery( + for: provider, + commandReturnedOutput: output != nil + ) + } + return discovered(models, defaultModel: defaultModel) + } + } + + private func failedDiscovery( + for provider: MobileTaskModelProvider, + commandReturnedOutput: Bool + ) async -> MobileTaskModelListResult { + let error: MobileTaskModelListError + if commandReturnedOutput { + error = .queryFailed + } else { + let availability = await commandRunner( + "command -v \(provider.rawValue)", + .seconds(2) + ) + error = availability == nil ? .providerUnavailable : .queryFailed } + return MobileTaskModelListResult( + models: [], + source: .fallback, + error: error + ) } private func discovered( - _ models: [MobileTaskModel] + _ models: [MobileTaskModel], + defaultModel: MobileTaskModel? = nil ) -> MobileTaskModelListResult { MobileTaskModelListResult( models: models, - source: models.isEmpty ? .fallback : .discovered + source: models.isEmpty && defaultModel == nil ? .fallback : .discovered, + defaultModel: defaultModel ) } + private func codexDefaultModel( + in models: [MobileTaskModel] + ) async -> MobileTaskModel? { + guard !models.isEmpty else { return nil } + let url = homeDirectory + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("config.toml") + guard let data = await fileReader(url), + let configuredID = parser.codexConfiguredModel(from: data) else { + return nil + } + return models.first { $0.id == configuredID } + } + private static let claudeModelListCommand = #""" set -o pipefail /usr/bin/printf '%s\n' '{"type":"control_request","request_id":"cmux-list-options","request":{"subtype":"list_models"}}' | /usr/bin/env CLAUDE_CODE_ENTRYPOINT=cmux-task-models claude -p --input-format stream-json --output-format stream-json --include-partial-messages --verbose diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelParserTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelParserTests.swift index 61d57f8a5a9..4bcb397527d 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelParserTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelParserTests.swift @@ -6,19 +6,40 @@ import Testing struct MobileTaskModelParserTests { private let parser = MobileTaskModelParser() - @Test func openCodeKeepsEveryNonblankLineAndDeduplicatesInOrder() { + @Test func openCodeReadsVariantsForEachExactModel() { let output = """ - - anthropic/claude-sonnet-5 - warning: unexpected output - - opencode/big-pickle anthropic/claude-sonnet-5 + { + "name": "Claude Sonnet 5", + "variants": { + "high": {"reasoning": "high"}, + "low": {"reasoning": "low"}, + "max": {"reasoning": "max"}, + "medium": {"reasoning": "medium"}, + "none": {"reasoning": "none"}, + "xhigh": {"reasoning": "xhigh"} + } + } + opencode/big-pickle + { + "name": "Big Pickle", + "variants": {} + } """ - #expect(parser.openCodeModelIDs(from: output) == [ - "anthropic/claude-sonnet-5", - "warning: unexpected output", - "opencode/big-pickle", + #expect(parser.openCodeModels(from: output) == [ + MobileTaskModel( + id: "anthropic/claude-sonnet-5", + displayName: "Claude Sonnet 5", + efforts: [ + MobileTaskModelEffort(id: "none", displayName: "None"), + MobileTaskModelEffort(id: "low", displayName: "Low"), + MobileTaskModelEffort(id: "medium", displayName: "Medium"), + MobileTaskModelEffort(id: "high", displayName: "High"), + MobileTaskModelEffort(id: "xhigh", displayName: "Xhigh"), + MobileTaskModelEffort(id: "max", displayName: "Max"), + ] + ), + MobileTaskModel(id: "opencode/big-pickle", displayName: "Big Pickle"), ]) } @@ -50,6 +71,11 @@ struct MobileTaskModelParserTests { { "slug": "gpt-5.6-sol", "display_name": "GPT-5.6 Sol", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + {"effort":"low","description":"Fast"}, + {"effort":"medium","description":"Balanced"} + ], "visibility": "list" }, { @@ -72,11 +98,49 @@ struct MobileTaskModelParserTests { """ #expect(parser.codexModels(from: output) == [ - MobileTaskModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol"), + MobileTaskModel( + id: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + efforts: [ + MobileTaskModelEffort( + id: "low", + displayName: "Low", + description: "Fast" + ), + MobileTaskModelEffort( + id: "medium", + displayName: "Medium", + description: "Balanced" + ), + ], + defaultEffortID: "medium" + ), MobileTaskModel(id: "gpt-5.6-luna", displayName: "gpt-5.6-luna"), ]) } + @Test func claudeReadsModelSpecificEffortsWithoutSharingThem() { + let output = #"{"type":"control_response","response":{"subtype":"success","request_id":"cmux-list-options","response":{"models":[{"value":"claude-opus","displayName":"Opus","supportedEffortLevels":["medium","high"],"defaultEffortLevel":"high"},{"value":"claude-haiku","displayName":"Haiku","supportedEffortLevels":["low"],"defaultEffortLevel":"low"}]}}}"# + + #expect(parser.claudeModels(from: output) == [ + MobileTaskModel( + id: "claude-opus", + displayName: "Opus", + efforts: [ + MobileTaskModelEffort(id: "medium", displayName: "Medium"), + MobileTaskModelEffort(id: "high", displayName: "High"), + ], + defaultEffortID: "high" + ), + MobileTaskModel( + id: "claude-haiku", + displayName: "Haiku", + efforts: [MobileTaskModelEffort(id: "low", displayName: "Low")], + defaultEffortID: "low" + ), + ]) + } + @Test func claudeReadsBedrockStyleModelIdentifier() { let data = Data(#"{"model":"us.anthropic.claude-opus-5","theme":"dark"}"#.utf8) #expect( diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelProviderStrategyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelProviderStrategyTests.swift index 038c2995a6b..5ad4b9d0c3d 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelProviderStrategyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelProviderStrategyTests.swift @@ -32,7 +32,9 @@ struct MobileTaskModelProviderStrategyTests { await probe.setMinimumCommandTimeout(.seconds(20)) await probe.setCommandOutput(""" test-provider/host-next-999 + {"name":"Host Next 999","variants":{"high":{}}} test-provider/host-second-998 + {"name":"Host Second 998","variants":{"low":{}}} """) let strategy = makeStrategy(probe: probe) @@ -42,25 +44,27 @@ struct MobileTaskModelProviderStrategyTests { models: [ MobileTaskModel( id: "test-provider/host-next-999", - displayName: "test-provider/host-next-999" + displayName: "Host Next 999", + efforts: [MobileTaskModelEffort(id: "high", displayName: "High")] ), MobileTaskModel( id: "test-provider/host-second-998", - displayName: "test-provider/host-second-998" + displayName: "Host Second 998", + efforts: [MobileTaskModelEffort(id: "low", displayName: "Low")] ), ], source: .discovered )) let commands = await probe.commands #expect(commands.count == 1) - #expect(commands.first?.0 == "opencode models") + #expect(commands.first?.0 == "opencode models --verbose") #expect(commands.first?.1 == .seconds(30)) #expect(await probe.readPaths.isEmpty) } @Test func claudeUsesControlStreamAsAuthoritativeCatalog() async { let probe = MobileTaskModelStrategyProbe() - await probe.setCommandOutput(#"{"type":"control_response","response":{"subtype":"success","request_id":"cmux-list-options","response":{"models":[{"value":"default","displayName":"Default"},{"value":"host-next-999","displayName":"Host Next 999"}]}}}"#) + await probe.setCommandOutput(#"{"type":"control_response","response":{"subtype":"success","request_id":"cmux-list-options","response":{"models":[{"value":"default","displayName":"Default","supportedEffortLevels":["low","medium","high"],"defaultEffortLevel":"medium"},{"value":"host-next-999","displayName":"Host Next 999"}]}}}"#) let result = await makeStrategy(probe: probe).models(for: .claude) @@ -68,7 +72,17 @@ struct MobileTaskModelProviderStrategyTests { models: [ MobileTaskModel(id: "host-next-999", displayName: "Host Next 999"), ], - source: .discovered + source: .discovered, + defaultModel: MobileTaskModel( + id: "default", + displayName: "Default", + efforts: [ + MobileTaskModelEffort(id: "low", displayName: "Low"), + MobileTaskModelEffort(id: "medium", displayName: "Medium"), + MobileTaskModelEffort(id: "high", displayName: "High"), + ], + defaultEffortID: "medium" + ) )) let commands = await probe.commands #expect(commands.count == 1) @@ -80,13 +94,22 @@ struct MobileTaskModelProviderStrategyTests { @Test func codexUsesDebugCatalogAsAuthoritativeCatalog() async { let probe = MobileTaskModelStrategyProbe() + await probe.setFile( + path: "/Users/tester/.codex/config.toml", + data: Data(#"model = "gpt-5.6-sol""#.utf8) + ) await probe.setCommandOutput(""" { "models": [ { "slug": "gpt-5.6-sol", "display_name": "GPT-5.6 Sol", - "visibility": "list" + "visibility": "list", + "supported_reasoning_levels": [ + {"effort":"low"}, + {"effort":"ultra"} + ], + "default_reasoning_level": "low" }, { "slug": "gpt-hidden", @@ -101,13 +124,30 @@ struct MobileTaskModelProviderStrategyTests { #expect(result == MobileTaskModelListResult( models: [ - MobileTaskModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol"), + MobileTaskModel( + id: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + efforts: [ + MobileTaskModelEffort(id: "low", displayName: "Low"), + MobileTaskModelEffort(id: "ultra", displayName: "Ultra"), + ], + defaultEffortID: "low" + ), ], - source: .discovered + source: .discovered, + defaultModel: MobileTaskModel( + id: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + efforts: [ + MobileTaskModelEffort(id: "low", displayName: "Low"), + MobileTaskModelEffort(id: "ultra", displayName: "Ultra"), + ], + defaultEffortID: "low" + ) )) #expect(await probe.commands.map(\.0) == ["exec codex debug models"]) #expect(await probe.commands.map(\.1) == [.seconds(5)]) - #expect(await probe.readPaths.isEmpty) + #expect(await probe.readPaths == ["/Users/tester/.codex/config.toml"]) } @Test func codexFallsBackToAgentOwnedCacheAfterDebugFailure() async { @@ -126,7 +166,10 @@ struct MobileTaskModelProviderStrategyTests { source: .discovered )) #expect(await probe.commands.map(\.0) == ["exec codex debug models"]) - #expect(await probe.readPaths == ["/Users/tester/.codex/models_cache.json"]) + #expect(await probe.readPaths == [ + "/Users/tester/.codex/models_cache.json", + "/Users/tester/.codex/config.toml", + ]) } @Test func failedAgentDiscoveryReturnsNoInventedValues() async { @@ -138,10 +181,27 @@ struct MobileTaskModelProviderStrategyTests { #expect(codex.source == .fallback) #expect(codex.models.isEmpty) + #expect(codex.error == .providerUnavailable) #expect(claude.source == .fallback) #expect(claude.models.isEmpty) + #expect(claude.error == .providerUnavailable) #expect(openCode.source == .fallback) #expect(openCode.models.isEmpty) + #expect(openCode.error == .providerUnavailable) + } + + @Test func failedQueryIsDistinctFromMissingAgent() async { + let probe = MobileTaskModelStrategyProbe() + await probe.setCommandOutput("") + let strategy = makeStrategy(probe: probe) + + let codex = await strategy.models(for: .codex) + let claude = await strategy.models(for: .claude) + let openCode = await strategy.models(for: .openCode) + + #expect(codex.error == .queryFailed) + #expect(claude.error == .queryFailed) + #expect(openCode.error == .queryFailed) } private func makeStrategy( diff --git a/Sources/TerminalController+MobileTaskModels.swift b/Sources/TerminalController+MobileTaskModels.swift index ec061d1c1cf..1040963c0db 100644 --- a/Sources/TerminalController+MobileTaskModels.swift +++ b/Sources/TerminalController+MobileTaskModels.swift @@ -35,14 +35,57 @@ extension TerminalController { ) } let result = await mobileTaskModelDiscovery.models(for: provider) - return .ok([ - "models": result.models.map { - [ - "id": $0.id, - "display_name": $0.displayName, - ] + func modelObject( + id: String, + displayName: String, + efforts: [(id: String, displayName: String, description: String?)], + defaultEffortID: String? + ) -> [String: Any] { + var object: [String: Any] = [ + "id": id, + "display_name": displayName, + "efforts": efforts.map { effort in + var effortObject: [String: Any] = [ + "id": effort.id, + "display_name": effort.displayName, + ] + if let description = effort.description { + effortObject["description"] = description + } + return effortObject + }, + ] + if let defaultEffortID { + object["default_effort_id"] = defaultEffortID + } + return object + } + var response: [String: Any] = [ + "models": result.models.map { model in + modelObject( + id: model.id, + displayName: model.displayName, + efforts: model.efforts.map { + (id: $0.id, displayName: $0.displayName, description: $0.description) + }, + defaultEffortID: model.defaultEffortID + ) }, "source": result.source.rawValue, - ]) + ] + if let error = result.error { + response["error"] = error.rawValue + } + if let defaultModel = result.defaultModel { + response["default_model"] = modelObject( + id: defaultModel.id, + displayName: defaultModel.displayName, + efforts: defaultModel.efforts.map { + (id: $0.id, displayName: $0.displayName, description: $0.description) + }, + defaultEffortID: defaultModel.defaultEffortID + ) + } + return .ok(response) } } diff --git a/agent-chat/adapters/claude.ts b/agent-chat/adapters/claude.ts index dff96adb8d8..58e6c53b5ca 100644 --- a/agent-chat/adapters/claude.ts +++ b/agent-chat/adapters/claude.ts @@ -18,8 +18,6 @@ const THINKING_CHOICES: OptionChoice[] = [ { value: "16384", label: "16k thinking" }, { value: "32768", label: "32k thinking" }, ]; -const EFFORT_CHOICES: OptionChoice[] = ["low", "medium", "high", "xhigh", "max"] - .map((value) => ({ value, label: value })); const CONTEXT_CHOICES: OptionChoice[] = [ { value: "200k", label: "200k" }, { value: "1m", label: "1M" }, @@ -48,7 +46,7 @@ export function claudeIndependentLaunchEnvironment( return launchEnvironment; } -function curatedClaudeModels(): Array<{ slug: string; label: string; description?: string; minVersion?: string; context?: boolean; fast?: boolean; deprecated?: boolean }> { +function curatedClaudeModels(): Array<{ slug: string; label: string; description?: string; minVersion?: string; context?: boolean; fast?: boolean; deprecated?: boolean; efforts?: OptionChoice[]; defaultEffort?: string }> { const remote = agentModelCatalog.provider("claude"); if (remote) return remote.models.map((model) => ({ slug: model.id, @@ -58,6 +56,8 @@ function curatedClaudeModels(): Array<{ slug: string; label: string; description context: model.supportsOneMillion === true, fast: model.fast, deprecated: model.deprecated === true, + efforts: model.efforts?.map((effort) => ({ value: effort.value, label: effort.label, description: effort.description })), + defaultEffort: model.defaultEffort, })); return agentModelCatalog.hasPayload ? [] : BUILT_IN_MODELS; } @@ -68,6 +68,7 @@ function defaultClaudeModel(): string { let claudeVersionCache: { value: string | null; fetchedAt: number; promise?: Promise } | null = null; interface ClaudeModelMeta { efforts: OptionChoice[]; + defaultEffort: string; supportsFastMode: boolean; context?: { base: string; extended: string }; } @@ -97,7 +98,7 @@ export const claudeAdapter: Adapter = { { id: "model", label: "Model", kind: "select", value: DEFAULT_CLAUDE_MODEL, choices: [{ value: DEFAULT_CLAUDE_MODEL, label: "Claude Sonnet 5" }], disabled: true, description: "Loads at start" }, { id: "permissionMode", label: "Mode", kind: "select", value: "acceptEdits", choices: PERMISSION_CHOICES }, { id: "thinking", label: "Thinking", kind: "select", value: "0", role: "thinking-budget", choices: THINKING_CHOICES }, - { id: "effort", label: "Effort", kind: "select", value: "medium", role: "effort", choices: EFFORT_CHOICES }, + { id: "effort", label: "Effort", kind: "select", value: "", role: "effort", choices: [], disabled: true, description: "Loads with model" }, { id: "fastMode", label: "Fast", kind: "toggle", value: false }, ], }, @@ -145,7 +146,7 @@ export const claudeAdapter: Adapter = { modelMeta: choices.meta, permissionMode: "acceptEdits", thinking: "0", - effort: "medium", + effort: "", fastMode: false, context: "200k", }; @@ -162,15 +163,26 @@ export const claudeAdapter: Adapter = { function state(sess: SessionCtx): ClaudeState { let st = sess.internal.claude as ClaudeState | undefined; if (!st) { + const seededModel = seededClaudeDefault(sess); + const seededChoices = sess.seedOptions?.find((option) => option.id === "model")?.choices; + const initialChoices = seededChoices?.length ? seededChoices : [{ value: seededModel, label: seededModel }]; + const initialMeta = new Map(); + for (const choice of initialChoices) { + initialMeta.set(choice.value, { + efforts: choice.efforts ?? [], + defaultEffort: validDefaultEffort(choice.efforts ?? [], choice.defaultEffort), + supportsFastMode: false, + }); + } st = { nextRequest: 1, pending: new Map(), model: normalizeStartModel(stringOption(sess, "model", seededClaudeDefault(sess))), - modelChoices: [{ value: seededClaudeDefault(sess), label: seededClaudeDefault(sess) }], - modelMeta: new Map([[seededClaudeDefault(sess), { efforts: EFFORT_CHOICES, supportsFastMode: false }]]), + modelChoices: initialChoices, + modelMeta: initialMeta, permissionMode: stringOption(sess, "permissionMode", sess.autoApprove ? "acceptEdits" : "default"), thinking: stringOption(sess, "thinking", "0"), - effort: stringOption(sess, "effort", "medium"), + effort: stringOption(sess, "effort", ""), fastMode: booleanOption(sess, "fastMode", false), context: stringOption(sess, "context", "200k"), initialApplied: false, @@ -231,7 +243,7 @@ function ensureProc(sess: SessionCtx): Bun.Subprocess<"pipe", "pipe", "pipe"> { if (fork?.providerSessionId) args.push("--resume", fork.providerSessionId, "--fork-session"); if (st.permissionMode !== "default") args.push("--permission-mode", st.permissionMode); if (sess.autoApprove) args.push("--allowedTools", "Bash Read Edit Write Glob Grep WebFetch WebSearch"); - if (typeof sess.startOptions.effort === "string") args.push("--effort", st.effort); + if (typeof sess.startOptions.effort === "string" && st.effort) args.push("--effort", st.effort); const proc = Bun.spawn(["claude", ...args], { cwd: sess.cwd, @@ -338,7 +350,7 @@ async function applyInitialOptions(sess: SessionCtx) { if (typeof sess.startOptions.thinking === "string" || st.thinking !== "0") { await control(sess, "set_max_thinking_tokens", { max_thinking_tokens: Number(st.thinking) || 0 }); } - if (typeof sess.startOptions.effort === "string" || st.effort !== "medium") { + if (st.effort) { await control(sess, "apply_flag_settings", { settings: { effortLevel: st.effort } }); } if (typeof sess.startOptions.fastMode === "boolean" || st.fastMode) { @@ -357,7 +369,7 @@ async function setClaudeOption(sess: SessionCtx, id: string, value: OptionValue) const model = resolveClaudeModelId(st); await control(sess, "set_model", { model }); const changed = normalizeEffort(st); - if (changed.effort) await control(sess, "apply_flag_settings", { settings: { effortLevel: st.effort } }); + if (changed.effort && st.effort) await control(sess, "apply_flag_settings", { settings: { effortLevel: st.effort } }); if (changed.fastMode) await control(sess, "apply_flag_settings", { settings: { fastMode: st.fastMode } }); break; } @@ -383,6 +395,9 @@ async function setClaudeOption(sess: SessionCtx, id: string, value: OptionValue) } case "effort": { if (typeof value !== "string") throw new Error("effort must be a string"); + if (!modelMeta(st).efforts.some((choice) => choice.value === value)) { + throw new Error(`unsupported effort for ${st.model}: ${value}`); + } await control(sess, "apply_flag_settings", { settings: { effortLevel: value } }); st.effort = value; break; @@ -419,6 +434,15 @@ function seedModelChoices(sess: SessionCtx, st: ClaudeState): boolean { const seeded = sess.seedOptions?.find((o) => o.id === "model")?.choices; if (!seeded || seeded.length <= 1 || st.modelChoices.length > 1) return false; st.modelChoices = seeded; + for (const choice of seeded) { + const current = st.modelMeta.get(choice.value); + st.modelMeta.set(choice.value, { + efforts: choice.efforts ?? [], + defaultEffort: validDefaultEffort(choice.efforts ?? [], choice.defaultEffort), + supportsFastMode: current?.supportsFastMode ?? false, + ...(current?.context ? { context: current.context } : {}), + }); + } return true; } @@ -440,14 +464,14 @@ function buildOptions(st: Pick): ClaudeModelMeta { - return st.modelMeta.get(st.model) ?? st.modelMeta.get(defaultClaudeModel()) ?? { efforts: EFFORT_CHOICES, supportsFastMode: false }; + return st.modelMeta.get(st.model) ?? { efforts: [], defaultEffort: "", supportsFastMode: false }; } function normalizeEffort(st: Pick): { effort: boolean; fastMode: boolean } { const meta = modelMeta(st); const beforeEffort = st.effort; const beforeFast = st.fastMode; - if (!meta.efforts.some((c) => c.value === st.effort)) st.effort = meta.efforts[0]?.value ?? "medium"; + if (!meta.efforts.some((c) => c.value === st.effort)) st.effort = validDefaultEffort(meta.efforts, meta.defaultEffort); if (!meta.supportsFastMode) st.fastMode = false; return { effort: st.effort !== beforeEffort, fastMode: st.fastMode !== beforeFast }; } @@ -496,10 +520,11 @@ function normalizeModelCatalog(models: any, version: string | null): { choices: supportsFastMode: m.supportsFastMode === true, efforts: Array.isArray(m.supportedEffortLevels) && m.supportedEffortLevels.length ? m.supportedEffortLevels.map((effort: unknown) => ({ value: String(effort), label: String(effort) })) - : EFFORT_CHOICES, + : [], + defaultEffort: typeof m.defaultEffortLevel === "string" ? m.defaultEffortLevel : "", }, }; - }).filter(Boolean) as Array<{ rawValue: string; value: string; base: string; suffix: string; label: string; description?: string; meta: { supportsFastMode: boolean; efforts: OptionChoice[] } }>; + }).filter(Boolean) as Array<{ rawValue: string; value: string; base: string; suffix: string; label: string; description?: string; meta: { supportsFastMode: boolean; efforts: OptionChoice[]; defaultEffort: string } }>; const rawByBase = new Map(); const extendedByBase = new Map(); for (const m of raw) if (!m.suffix && !rawByBase.has(m.base)) rawByBase.set(m.base, m); @@ -508,15 +533,17 @@ function normalizeModelCatalog(models: any, version: string | null): { choices: const disabledReason = model.minVersion && version && !versionAtLeast(version, model.minVersion) ? claudeUpgradeMessage(model.slug, model.label, model.minVersion, version) : model.deprecated ? `${model.label} is deprecated.` : undefined; - choices.push({ value: model.slug, label: model.label, description: model.description, disabled: Boolean(disabledReason), disabledReason }); - covered.add(model.slug); - covered.add(aliasClaudeModel(stripOneMillion(model.slug).base)); const extended = extendedByBase.get(model.slug)?.value ?? `${model.slug}[1m]`; const context = model.context || extendedByBase.has(model.slug) ? { base: model.slug, extended } : undefined; const binary = rawByBase.get(model.slug); - meta.set(model.slug, { efforts: binary?.meta.efforts ?? EFFORT_CHOICES, supportsFastMode: model.fast ?? binary?.meta.supportsFastMode ?? false, ...(context ? { context } : {}) }); + const efforts = model.efforts ?? binary?.meta.efforts ?? []; + const defaultEffort = validDefaultEffort(efforts, model.defaultEffort ?? binary?.meta.defaultEffort); + choices.push({ value: model.slug, label: model.label, description: model.description, disabled: Boolean(disabledReason), disabledReason, efforts, defaultEffort }); + covered.add(model.slug); + covered.add(aliasClaudeModel(stripOneMillion(model.slug).base)); + meta.set(model.slug, { efforts, defaultEffort, supportsFastMode: model.fast ?? binary?.meta.supportsFastMode ?? false, ...(context ? { context } : {}) }); } for (const m of raw) { if (covered.has(m.base)) { @@ -527,17 +554,25 @@ function normalizeModelCatalog(models: any, version: string | null): { choices: if (m.suffix && rawByBase.has(m.base)) continue; if (extendedByBase.has(m.base)) { const extended = extendedByBase.get(m.base)!; - meta.set(m.base, { efforts: m.meta.efforts, supportsFastMode: m.meta.supportsFastMode, context: { base: m.base, extended: extended.value } }); - choices.push({ value: m.base, label: m.label, description: m.description }); + const defaultEffort = validDefaultEffort(m.meta.efforts, m.meta.defaultEffort); + meta.set(m.base, { efforts: m.meta.efforts, defaultEffort, supportsFastMode: m.meta.supportsFastMode, context: { base: m.base, extended: extended.value } }); + choices.push({ value: m.base, label: m.label, description: m.description, efforts: m.meta.efforts, defaultEffort }); } else { - meta.set(m.base, { efforts: m.meta.efforts, supportsFastMode: m.meta.supportsFastMode }); - choices.push({ value: m.base, label: m.label, description: m.description }); + const defaultEffort = validDefaultEffort(m.meta.efforts, m.meta.defaultEffort); + meta.set(m.base, { efforts: m.meta.efforts, defaultEffort, supportsFastMode: m.meta.supportsFastMode }); + choices.push({ value: m.base, label: m.label, description: m.description, efforts: m.meta.efforts, defaultEffort }); } covered.add(m.base); } return { choices: dedupeChoices(choices), meta }; } +function validDefaultEffort(efforts: OptionChoice[], requested?: string): string { + return requested && efforts.some((effort) => effort.value === requested) + ? requested + : efforts[0]?.value ?? ""; +} + async function fetchClaudeVersion(): Promise { const now = Date.now(); if (claudeVersionCache?.promise) return claudeVersionCache.promise; diff --git a/agent-chat/adapters/codex.ts b/agent-chat/adapters/codex.ts index b17611dd1b1..e64ce741135 100644 --- a/agent-chat/adapters/codex.ts +++ b/agent-chat/adapters/codex.ts @@ -46,7 +46,6 @@ interface CodexState { let shared: AppServer | null = null; let sharedStarting: Promise | null = null; -const FALLBACK_EFFORTS: OptionChoice[] = ["low", "medium", "high", "xhigh"].map((value) => ({ value, label: value })); const APPROVAL_CHOICES: OptionChoice[] = [ { value: "untrusted", label: "Untrusted" }, { value: "on-request", label: "On request" }, @@ -64,7 +63,7 @@ export const codexAdapter: Adapter = { triggers: ["$"], options: [ { id: "model", label: "Model", kind: "select", value: "", disabled: true, description: "Loads at start" }, - { id: "effort", label: "Effort", kind: "select", value: "medium", role: "effort", choices: FALLBACK_EFFORTS }, + { id: "effort", label: "Effort", kind: "select", value: "", role: "effort", choices: [], disabled: true, description: "Loads with model" }, { id: "approvals", label: "Approvals", kind: "select", value: "never", choices: APPROVAL_CHOICES }, { id: "sandbox", label: "Sandbox", kind: "select", value: "workspace-write", choices: SANDBOX_CHOICES }, { id: "mode", label: "Mode", kind: "select", value: "default", choices: [{ value: "default", label: "Default" }, { value: "plan", label: "Plan" }] }, @@ -460,7 +459,7 @@ function defaultState(autoApprove: boolean): CodexState { models: [], modes: [{ value: "default", label: "Default" }, { value: "plan", label: "Plan" }], model: "", - effort: "medium", + effort: "", approvals: autoApprove ? "never" : "on-request", sandbox: autoApprove ? "workspace-write" : "read-only", fastMode: false, @@ -554,6 +553,9 @@ async function setCodexOption(sess: SessionCtx, id: string, value: OptionValue) break; case "effort": if (typeof value !== "string") throw new Error("effort must be a string"); + if (!effortForModel(st).choices.some((choice) => choice.value === value)) { + throw new Error(`unsupported effort for ${st.model}: ${value}`); + } st.effort = value; break; case "fastMode": @@ -603,7 +605,13 @@ function buildOptions(st: CodexState): SessionOption[] { label: "Model", kind: "select", value: st.model, - choices: st.models.map((m) => ({ value: m.value, label: m.label, description: m.description })), + choices: st.models.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + efforts: m.efforts, + defaultEffort: m.defaultEffort, + })), disabled: !st.models.length, }, { id: "effort", label: "Effort", kind: "select", value: st.effort, role: "effort", choices: effort.choices }, @@ -638,9 +646,9 @@ export function mergeCodexModels(binaryModels: ModelInfo[], remote = agentModelC const remoteEfforts = (model.efforts ?? []) .map((effort) => ({ value: effort.value, label: effort.label, description: effort.description })) .filter((effort) => !isOffLike(effort.value)); - const efforts = remoteEfforts.length ? remoteEfforts : reported?.efforts ?? FALLBACK_EFFORTS; + const efforts = model.efforts ? remoteEfforts : reported?.efforts ?? []; const requestedEffort = model.defaultEffort ?? reported?.defaultEffort ?? ""; - const defaultEffort = efforts.some((effort) => effort.value === requestedEffort) ? requestedEffort : efforts[0]?.value ?? "medium"; + const defaultEffort = efforts.some((effort) => effort.value === requestedEffort) ? requestedEffort : efforts[0]?.value ?? ""; return { value: model.id, label: model.label, @@ -663,13 +671,13 @@ function normalizeModel(m: any): ModelInfo { label: String(e.reasoningEffort ?? e), description: e.description ? String(e.description) : undefined, })).filter((e: OptionChoice) => !isOffLike(e.value)) - : FALLBACK_EFFORTS; + : []; return { value: String(m.model ?? m.id), label: prettifyModelLabel(String(m.displayName ?? m.model ?? m.id)), description: m.description ? String(m.description) : undefined, efforts, - defaultEffort: String(m.defaultReasoningEffort ?? efforts[0]?.value ?? "medium"), + defaultEffort: String(m.defaultReasoningEffort ?? efforts[0]?.value ?? ""), serviceTiers: (m.serviceTiers ?? []).map((t: any) => ({ id: String(t.id), name: String(t.name ?? t.id), @@ -722,10 +730,10 @@ function selectedModel(st: CodexState): ModelInfo | undefined { function effortForModel(st: CodexState): { value: string; choices: OptionChoice[] } { const m = selectedModel(st); - const choices = m?.efforts.length ? m.efforts : FALLBACK_EFFORTS; + const choices = m?.efforts ?? []; const value = choices.some((c) => c.value === st.effort) ? st.effort - : (m?.defaultEffort && choices.some((c) => c.value === m.defaultEffort) ? m.defaultEffort : choices[0]?.value ?? "medium"); + : (m?.defaultEffort && choices.some((c) => c.value === m.defaultEffort) ? m.defaultEffort : choices[0]?.value ?? ""); return { value, choices }; } diff --git a/agent-chat/adapters/pi.ts b/agent-chat/adapters/pi.ts index d25be0b7bfa..01a5efefe26 100644 --- a/agent-chat/adapters/pi.ts +++ b/agent-chat/adapters/pi.ts @@ -2,9 +2,6 @@ import type { Adapter, CommandEntry, OptionChoice, OptionValue, SessionCtx, Sess import { readLines, tryParse, truncate } from "./lines"; import { prettifyProviderModelLabel } from "./model-label"; -const THINKING_CHOICES: OptionChoice[] = ["minimal", "low", "medium", "high", "xhigh"] - .map((value) => ({ value, label: value })); - interface PiState { proc?: Bun.Subprocess<"pipe", "pipe", "pipe">; nextId: number; @@ -12,7 +9,6 @@ interface PiState { model: string; modelChoices: OptionChoice[]; thinking: string; - thinkingNormalized: boolean; sessionFile?: string; commands: CommandEntry[]; initialApplied: boolean; @@ -25,7 +21,7 @@ export const piAdapter: Adapter = { triggers: ["/"], options: [ { id: "model", label: "Model", kind: "select", value: "", disabled: true, description: "Loads at start" }, - { id: "thinking", label: "Thinking", kind: "select", value: "minimal", role: "effort", choices: THINKING_CHOICES }, + { id: "thinking", label: "Thinking", kind: "select", value: "", role: "effort", choices: [], disabled: true, description: "Loads with model" }, ], }, async send(sess, prompt, generation?: number) { @@ -66,7 +62,7 @@ export const piAdapter: Adapter = { return buildOptions({ model: models[0]?.value ?? "", modelChoices: models, - thinking: "minimal", + thinking: "", }); }, async listCommands(cwd) { @@ -90,8 +86,7 @@ function state(sess: SessionCtx): PiState { pending: new Map(), model: typeof sess.startOptions.model === "string" ? sess.startOptions.model : "", modelChoices: [], - thinking: typeof sess.startOptions.thinking === "string" ? sess.startOptions.thinking : "off", - thinkingNormalized: false, + thinking: typeof sess.startOptions.thinking === "string" ? sess.startOptions.thinking : "", sessionFile: typeof sess.internal.piSessionFile === "string" ? sess.internal.piSessionFile : undefined, commands: [], initialApplied: false, @@ -168,9 +163,10 @@ async function applyInitialOptions(sess: SessionCtx) { const st = state(sess); if (st.initialApplied) return; st.initialApplied = true; + const requestedThinking = typeof sess.startOptions.thinking === "string" ? sess.startOptions.thinking : ""; if (typeof sess.startOptions.model === "string") await setPiOption(sess, "model", st.model); - if (typeof sess.startOptions.thinking === "string") await setPiOption(sess, "thinking", st.thinking); if (!st.modelChoices.length || !st.commands.length) await refreshPi(sess); + if (requestedThinking) await setPiOption(sess, "thinking", requestedThinking); await captureState(sess); } @@ -185,14 +181,20 @@ async function setPiOption(sess: SessionCtx, id: string, value: OptionValue) { const modelId = value.slice(slash + 1); await request(sess, { type: "set_model", provider, modelId }); st.model = value; + const thinking = thinkingForModel(st); + if (!thinking.choices.some((choice) => choice.value === st.thinking)) { + st.thinking = thinking.value; + if (st.thinking) await request(sess, { type: "set_thinking_level", level: st.thinking }); + } break; } case "thinking": if (typeof value !== "string") throw new Error("thinking must be a string"); - value = normalizeThinking(value); + if (!thinkingForModel(st).choices.some((choice) => choice.value === value)) { + throw new Error(`unsupported thinking level for ${st.model}: ${value}`); + } await request(sess, { type: "set_thinking_level", level: value }); st.thinking = value; - st.thinkingNormalized = true; break; default: throw new Error(`unsupported pi option: ${id}`); @@ -207,10 +209,10 @@ async function refreshPi(sess: SessionCtx) { const models = await request(sess, { type: "get_available_models" }); st.modelChoices = normalizeModels(models?.models ?? models?.data?.models); if (!st.model) st.model = st.modelChoices[0]?.value ?? ""; - if (!st.thinkingNormalized && isOffLike(st.thinking)) { - await request(sess, { type: "set_thinking_level", level: "minimal" }); - st.thinking = "minimal"; - st.thinkingNormalized = true; + const thinking = thinkingForModel(st); + if (!thinking.choices.some((choice) => choice.value === st.thinking)) { + st.thinking = thinking.value; + if (st.thinking) await request(sess, { type: "set_thinking_level", level: st.thinking }); } emitOptions(sess); const commands = await request(sess, { type: "get_commands" }); @@ -244,18 +246,22 @@ async function captureState(sess: SessionCtx) { } function buildOptions(st: Pick): SessionOption[] { + const thinking = thinkingForModel(st); return [ { id: "model", label: "Model", kind: "select", value: st.model, choices: st.modelChoices, disabled: !st.modelChoices.length }, - { id: "thinking", label: "Thinking", kind: "select", value: normalizeThinking(st.thinking), role: "effort", choices: THINKING_CHOICES }, + { id: "thinking", label: "Thinking", kind: "select", value: thinking.value, role: "effort", choices: thinking.choices }, ]; } -function normalizeThinking(value: string): string { - return isOffLike(value) ? "minimal" : value; -} - -function isOffLike(value: string): boolean { - return /^(off|none)$/i.test(value); +function thinkingForModel(st: Pick): { value: string; choices: OptionChoice[] } { + const model = st.modelChoices.find((choice) => choice.value === st.model); + const choices = model?.efforts ?? []; + const value = choices.some((choice) => choice.value === st.thinking) + ? st.thinking + : choices.some((choice) => choice.value === model?.defaultEffort) + ? model!.defaultEffort! + : choices[0]?.value ?? ""; + return { value, choices }; } function finishTurn(sess: SessionCtx) { @@ -368,11 +374,27 @@ export function piNextSendTypeForTest(sess: SessionCtx): "prompt" | "steer" { function normalizeModels(models: any): OptionChoice[] { if (!Array.isArray(models)) return []; - return models.map((m) => ({ - value: `${m.provider}/${m.id}`, - label: prettifyProviderModelLabel(String(m.provider), String(m.id), m.name ? String(m.name) : undefined), - description: m.reasoning ? "supports thinking" : undefined, - })); + return models.map((m) => { + const reportedEfforts = m.supportedThinkingLevels + ?? m.supportedReasoningEfforts + ?? m.supportedEffortLevels + ?? m.efforts; + const efforts: OptionChoice[] = Array.isArray(reportedEfforts) + ? reportedEfforts.flatMap((entry: unknown) => { + const raw = typeof entry === "string" ? entry : String((entry as any)?.value ?? (entry as any)?.level ?? ""); + if (!raw || /^(off|none)$/i.test(raw)) return []; + return [{ value: raw, label: typeof entry === "object" && entry ? String((entry as any).label ?? raw) : raw }]; + }) + : []; + const requested = String(m.defaultThinkingLevel ?? m.defaultReasoningEffort ?? m.defaultEffort ?? ""); + return { + value: `${m.provider}/${m.id}`, + label: prettifyProviderModelLabel(String(m.provider), String(m.id), m.name ? String(m.name) : undefined), + description: m.reasoning ? "supports thinking" : undefined, + efforts, + defaultEffort: efforts.some((effort) => effort.value === requested) ? requested : efforts[0]?.value, + }; + }); } function normalizeCommands(commands: any): CommandEntry[] { diff --git a/agent-chat/package.json b/agent-chat/package.json index 8d33c687988..68fc7cd27ca 100644 --- a/agent-chat/package.json +++ b/agent-chat/package.json @@ -5,7 +5,7 @@ "scripts": { "start": "bun server.ts", "build": "bun run build.ts", - "check": "bun x tsc --noEmit && bun test/claude-environment.test.ts && bun test/model-label.test.ts && bun test/activity.test.ts && bun test/keymap.test.ts && bun test/options-store.test.ts && bun test/options-ui.test.ts && bun test/turns.test.ts && bun test/selection-css.test.ts && bun test/highlight.test.ts" + "check": "bun x tsc --noEmit && bun test/claude-environment.test.ts && bun test/model-label.test.ts && bun test/model-picker-loading.test.ts && bun test/activity.test.ts && bun test/keymap.test.ts && bun test/options-store.test.ts && bun test/options-ui.test.ts && bun test/turns.test.ts && bun test/selection-css.test.ts && bun test/highlight.test.ts" }, "dependencies": { "@base-ui-components/react": "^1.0.0-rc.0", diff --git a/agent-chat/public/app.css b/agent-chat/public/app.css index 4ab274969a0..336928ade8d 100644 --- a/agent-chat/public/app.css +++ b/agent-chat/public/app.css @@ -150,6 +150,24 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default .static-provider:hover { background: transparent; color: color-mix(in srgb, var(--fg) 70%, var(--bg)); } .select-trigger[data-popup-open] .chev { transform: rotate(180deg); } .row-control .chev { display: inline-flex; opacity: .55; margin-left: 1px; } +.model-picker-trigger-loading { + width: 0; height: 18px; flex: none; display: inline-flex; align-items: center; justify-content: center; + color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); border-radius: 999px; + opacity: 0; transform: scale(.72); overflow: hidden; visibility: hidden; + transition: width 110ms ease-out, opacity 90ms ease-out, transform 110ms ease-out, visibility 0s linear 110ms; +} +.model-picker-trigger-loading[data-visible="true"] { + width: 18px; opacity: 1; transform: scale(1); visibility: visible; + transition-delay: 80ms; + transition-timing-function: cubic-bezier(.16, 1, .3, 1); +} +.effort-picker-loading { + height: 24px; max-width: 150px; display: inline-flex; align-items: center; gap: 6px; padding: 0 7px; + color: var(--text-dim); background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 6px; + font: 500 calc(var(--font-size-base) - 3px) var(--font-sans); + animation: loadingControlIn 130ms 80ms cubic-bezier(.16, 1, .3, 1) both; +} +@keyframes loadingControlIn { from { opacity: 0; transform: translateY(1px) scale(.96); } } .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--text-faint); box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 12%, transparent); @@ -218,6 +236,7 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default } .model-picker-rail-top, .model-picker-rail-bottom { display: flex; flex-direction: column; gap: 4px; align-items: center; } .rail-btn { + position: relative; width: 30px; height: 30px; border: 0; border-radius: 8px; background: transparent; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; padding: 0; cursor: default; } @@ -225,8 +244,12 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default .rail-btn.active { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent); } .rail-btn.missing { opacity: .36; } .rail-btn.missing:hover { opacity: .7; } +.rail-loading-indicator { + position: absolute; right: 1px; bottom: 1px; width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; + color: var(--accent); background: var(--bg-raised); border-radius: 999px; box-shadow: 0 0 0 1px var(--bg-raised); +} .model-picker-main { - min-width: 0; flex: 1; display: flex; flex-direction: column; + position: relative; min-width: 0; flex: 1; display: flex; flex-direction: column; border-left: 1px solid var(--border); } .model-picker-search { @@ -242,6 +265,24 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default flex: 1; min-height: 0; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 1px; mask-image: linear-gradient(to bottom, transparent 0, #000 10px, #000 calc(100% - 10px), transparent 100%); } +.model-picker-loading { + flex: none; display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 34px; + color: var(--text-dim); background: color-mix(in srgb, var(--accent) 5%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + font: 500 calc(var(--font-size-base) - 1px) var(--font-sans); +} +.model-picker-loading .pinwheel-spinner { color: var(--accent); } +.model-picker-loading-full { + position: absolute; inset: 39px 0 0; z-index: 2; min-height: 0; flex-direction: column; gap: 11px; border-bottom: 0; + background: radial-gradient(circle at center, color-mix(in srgb, var(--accent) 9%, transparent), transparent 58%); + opacity: 0; transform: scale(.985); visibility: hidden; pointer-events: none; + transition: opacity 110ms ease-out, transform 130ms ease-out, visibility 0s linear 130ms; +} +.model-picker-loading-full[data-visible="true"] { + opacity: 1; transform: scale(1); visibility: visible; + transition-delay: 80ms; + transition-timing-function: cubic-bezier(.16, 1, .3, 1); +} .model-row { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-dim); padding: 6px 8px; text-align: left; cursor: default; outline: none; @@ -632,9 +673,12 @@ details.thinking .t-body { white-space: pre-wrap; font-size: calc(var(--font-siz .activity-label { animation: none; opacity: .68; } .provider-running .provider-icon, .provider-running .provider-icon-img, - .pinwheel-spinner { + .pinwheel-spinner, + .effort-picker-loading { animation: none; } + .model-picker-trigger-loading, + .model-picker-loading-full { transition: none; } .disclosure-motion, .activity-caret-icon { transition: none !important; diff --git a/agent-chat/server.ts b/agent-chat/server.ts index 81601e22cfc..859b156354e 100644 --- a/agent-chat/server.ts +++ b/agent-chat/server.ts @@ -615,12 +615,19 @@ async function sanitizeStartOptions(provider: string, cwd: string, raw: Record, catalog: SessionOption[]): Record { const byId = new Map(catalog.map((o) => [o.id, o])); + const modelOption = byId.get("model"); + const requestedModel = typeof raw.model === "string" + ? modelOption?.choices?.find((choice) => choice.value === raw.model && !choice.disabled) + : undefined; const out: Record = {}; for (const [id, value] of Object.entries(raw)) { const option = byId.get(id); if (!option) continue; if (option.kind === "toggle" && typeof value === "boolean") out[id] = value; - if (option.kind === "select" && typeof value === "string" && option.choices?.some((c) => c.value === value && !c.disabled)) out[id] = value; + const choices = option.role === "effort" && requestedModel + ? requestedModel.efforts ?? [] + : option.choices ?? []; + if (option.kind === "select" && typeof value === "string" && choices.some((c) => c.value === value && !c.disabled)) out[id] = value; } return out; } @@ -629,6 +636,10 @@ function fallbackOptions(provider: string): SessionOption[] { return adapters.get(provider)?.capabilities?.options ?? []; } +function isOffLikeEffort(value: string): boolean { + return /^(off|none|no[-_ ]?reasoning)$/i.test(value); +} + function mergeRemoteModelOptions(provider: string, options: SessionOption[], remote = agentModelCatalog.provider(provider)): SessionOption[] { if (!remote) return options; const model = options.find((option) => option.id === "model" && option.kind === "select"); @@ -636,7 +647,25 @@ function mergeRemoteModelOptions(provider: string, options: SessionOption[], rem const choices: import("./types").OptionChoice[] = remote.models.map((entry) => { const reported = binary.get(entry.id); binary.delete(entry.id); - return { ...reported, value: entry.id, label: entry.label, description: entry.description ?? reported?.description }; + const efforts = entry.efforts + ?.filter((effort) => !isOffLikeEffort(effort.value)) + .map((effort) => ({ + value: effort.value, + label: effort.label, + description: effort.description, + })); + return { + ...reported, + value: entry.id, + label: entry.label, + description: entry.description ?? reported?.description, + ...(efforts?.length ? { + efforts, + defaultEffort: efforts.some((effort) => effort.value === entry.defaultEffort) + ? entry.defaultEffort + : efforts[0]!.value, + } : {}), + }; }); choices.push(...binary.values()); const current = typeof model?.value === "string" ? choices.find((choice) => choice.value === model.value && !choice.disabled) : undefined; diff --git a/agent-chat/src/components/Chat.tsx b/agent-chat/src/components/Chat.tsx index d4bff46a071..b4e1c2064af 100644 --- a/agent-chat/src/components/Chat.tsx +++ b/agent-chat/src/components/Chat.tsx @@ -4,12 +4,12 @@ import { readStoredProviderOptions, persistOptionsSnapshot, updateStoredProvider import type { OptionValue, SessionOption } from "../session"; import { ArrowUp } from "./icons"; import { isCtrlJ, insertNewlineAtCaret, useCommandMenu } from "./CommandMenu"; -import { optionAcceptsValue } from "./options"; +import { optionAcceptsValue, optionsForSelectedModel } from "./options"; import { StatusRow } from "./StatusRow"; import { Blocks } from "./Transcript"; import { ShortcutOverlay, useKeymap } from "../hooks/useKeymap"; import { useAutoGrow } from "../hooks/useAutoGrow"; -import { providerOptionMap, useFileCatalog, useProviderCatalogs, withFileTrigger } from "../hooks/useCatalogs"; +import { loadingProviderOptionIds, providerOptionMap, useFileCatalog, useProviderCatalogs, withFileTrigger } from "../hooks/useCatalogs"; function usePersistSessionOptions(provider: string | undefined, options: SessionOption[], skip = false) { useEffect(() => { @@ -66,15 +66,20 @@ export function Chat() { const commandGroups = useMemo(() => withFileTrigger(commands, filesByCwd[cwd] ?? []), [commands, cwd, filesByCwd]); const commandMenu = useCommandMenu(text, setText, commandGroups, taRef, ctrlJ); const allProviderOptions = providerOptionMap(providers, providerOptions, capabilities); + const loadingProviderIds = useMemo( + () => loadingProviderOptionIds(providers, providerOptions), + [providerOptions, providers], + ); const running = session?.status === "running"; + const resolvedOptions = useMemo(() => optionsForSelectedModel(options), [options]); - useRestoreModelScopedOptions({ provider: session?.provider, options, setOption, pendingModelRestoreRef }); - usePersistSessionOptions(session?.provider, options, pendingModelRestoreRef.current !== null); + useRestoreModelScopedOptions({ provider: session?.provider, options: resolvedOptions, setOption, pendingModelRestoreRef }); + usePersistSessionOptions(session?.provider, resolvedOptions, pendingModelRestoreRef.current !== null); useProviderCatalogs(ready, connectionEpoch, providers, session?.provider ?? "", cwd, requestProviderOptions, requestProviderCommands); useFileCatalog(ready, connectionEpoch, cwd, requestFiles); useStickToBottom(scrollRef, stickRef, blocks, running); useKeymap({ - options, + options: resolvedOptions, setOption, running, stop, @@ -105,7 +110,7 @@ export function Chat() { if (!session) return; if (provider === session.provider) { if (model) { - updateStoredProviderOption(provider, "model", model, options); + updateStoredProviderOption(provider, "model", model, resolvedOptions); pendingModelRestoreRef.current = model; setOption("model", model); } @@ -157,9 +162,10 @@ export function Chat() { provider={session?.provider ?? "agent"} providers={providers} allProviderOptions={allProviderOptions} + loadingProviderIds={loadingProviderIds} onProviderModelChange={switchHarnessModel} cwd={session?.cwd ?? ""} - options={options} + options={resolvedOptions} onChange={setOption} openOptionId={openOptionId} setOpenOptionId={setOpenOptionId} @@ -175,7 +181,7 @@ export function Chat() { /> - {helpOpen ? setHelpOpen(false)} /> : null} + {helpOpen ? setHelpOpen(false)} /> : null} ); } diff --git a/agent-chat/src/components/Composer.tsx b/agent-chat/src/components/Composer.tsx index 20660ad0def..91efb66d6fd 100644 --- a/agent-chat/src/components/Composer.tsx +++ b/agent-chat/src/components/Composer.tsx @@ -9,6 +9,7 @@ import { sanitizeStartOptions, withLocalValues } from "./options"; import { ShortcutOverlay, useKeymap } from "../hooks/useKeymap"; import { useAutoGrow } from "../hooks/useAutoGrow"; import { + loadingProviderOptionIds, providerOptionMap, useCwdErrorFallback, useCwdValidation, @@ -57,6 +58,10 @@ export function Composer() { const taRef = useAutoGrow(prompt, 300); const baseOptions = providerOptions[provider]?.length ? providerOptions[provider] : capabilities[provider]?.options ?? []; const allProviderOptions = providerOptionMap(providers, providerOptions, capabilities); + const loadingProviderIds = useMemo( + () => loadingProviderOptionIds(providers, providerOptions), + [providerOptions, providers], + ); const startOptions = startOptionsByProvider[provider] ?? {}; const options = withLocalValues(baseOptions, startOptions); const commandGroups = useMemo(() => withFileTrigger(providerCommands[provider] ?? [], filesByCwd[committedCwd] ?? []), [committedCwd, filesByCwd, provider, providerCommands]); @@ -152,6 +157,7 @@ export function Composer() { provider={provider} providers={providers} allProviderOptions={allProviderOptions} + loadingProviderIds={loadingProviderIds} onProviderModelChange={changeProviderModel} cwd={cwd} onCwdChange={changeCwd} diff --git a/agent-chat/src/components/StatusRow.tsx b/agent-chat/src/components/StatusRow.tsx index 801a7aece42..f8158234945 100644 --- a/agent-chat/src/components/StatusRow.tsx +++ b/agent-chat/src/components/StatusRow.tsx @@ -2,10 +2,10 @@ import { Popover } from "@base-ui-components/react/popover"; import { useCallback, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type RefObject, type SetStateAction } from "react"; import { menuActionForKey } from "../keymap"; import type { OptionValue, Provider, SessionOption } from "../session"; -import { BarsIcon, BoltIcon, Check, Chevron, EllipsisIcon, FolderIcon, PlanIcon, ProviderIcon, SearchIcon, ShieldIcon, SparkIcon, basename } from "./icons"; +import { BarsIcon, BoltIcon, Check, Chevron, EllipsisIcon, FolderIcon, PinwheelSpinner, PlanIcon, ProviderIcon, SearchIcon, ShieldIcon, SparkIcon, basename } from "./icons"; import { CmdkMenu, type CmdkGroup } from "./CmdkMenu"; import { HintTooltip } from "./Tooltips"; -import { currentChoice, cycleSelect, effortFill, isOffLikeValue, optionAction, optionTooltip, prettyValue, visibleChoices } from "./options"; +import { currentChoice, cycleSelect, effortFill, isOffLikeValue, optionAction, optionTooltip, optionsForSelectedModel, prettyValue, visibleChoices } from "./options"; function CwdPopover({ cwd, onChange, onCommit }: { cwd: string; onChange: (v: string) => void; onCommit: (v: string) => void }) { return ( @@ -213,7 +213,32 @@ interface PickerModelItem { search: string; } -function providerModelItems(p: Provider, currentProvider: string, options: SessionOption[]): PickerModelItem[] { +const modelLoadingMessages = { + en: "Loading models", + ja: "モデルを読み込み中", +} as const; +const effortLoadingMessages = { + en: "Loading effort", + ja: "エフォートを読み込み中", +} as const; +const noLoadingProviderIds: ReadonlySet = new Set(); + +function localizedLoadingLabel(messages: typeof modelLoadingMessages | typeof effortLoadingMessages): string { + const browserNavigator = typeof navigator === "undefined" ? undefined : navigator; + const languages = browserNavigator?.languages?.length + ? browserNavigator.languages + : browserNavigator?.language ? [browserNavigator.language] : []; + const supportedLanguage = languages.find((language) => /^(en|ja)(-|$)/i.test(language)); + return supportedLanguage?.toLowerCase().startsWith("ja") + ? messages.ja + : messages.en; +} + +function modelLoadingLabel(): string { return localizedLoadingLabel(modelLoadingMessages); } +function effortLoadingLabel(): string { return localizedLoadingLabel(effortLoadingMessages); } + +export function providerModelItemsForState(p: Provider, currentProvider: string, options: SessionOption[], isLoading: boolean): PickerModelItem[] { + if (isLoading) return []; const model = modelOption(options); const choices = model?.choices?.length ? model.choices : []; if (!choices.length) { @@ -262,6 +287,7 @@ export function HarnessModelPicker({ providers, options, allProviderOptions, + loadingProviderIds, open, onOpenChange, onSelect, @@ -272,6 +298,7 @@ export function HarnessModelPicker({ providers: Provider[]; options: SessionOption[]; allProviderOptions: Record; + loadingProviderIds: ReadonlySet; open: boolean; onOpenChange: (open: boolean) => void; onSelect: (provider: string, model: string) => void; @@ -283,6 +310,8 @@ export function HarnessModelPicker({ const currentProvider = providers.find((p) => p.id === provider) ?? { id: provider, label: provider }; const currentModel = modelOption(options); const label = currentChoice(currentModel)?.label ?? String(currentModel?.value || currentProvider.label); + const loadingLabel = modelLoadingLabel(); + const isCurrentProviderLoading = loadingProviderIds.has(provider); const [railProvider, setRailProvider] = useState(provider); const [query, setQuery] = useState(initialQuery); const searchRef = useRef(null); @@ -294,8 +323,8 @@ export function HarnessModelPicker({ const activeProvider = installed.find((p) => p.id === railProvider) ?? installed.find((p) => p.id === provider) ?? installed[0]; const providerItems = useMemo(() => new Map(installed.map((p) => { const opts = p.id === provider ? options : (allProviderOptions[p.id] ?? []); - return [p.id, providerModelItems(p, provider, opts)]; - })), [allProviderOptions, installed, options, provider]); + return [p.id, providerModelItemsForState(p, provider, opts, loadingProviderIds.has(p.id))]; + })), [allProviderOptions, installed, loadingProviderIds, options, provider]); const q = query.trim(); const listItems = useMemo(() => { if (q) { @@ -308,6 +337,9 @@ export function HarnessModelPicker({ return activeProvider ? providerItems.get(activeProvider.id) ?? [] : []; }, [activeProvider, providerItems, q]); const [activeIndex, setActiveIndex] = useBoundedActiveIndex(open, `${q}:${activeProvider?.id ?? ""}:${listItems.map((i) => i.id).join("|")}`, listItems.length); + const isListLoading = q + ? installed.some((candidate) => loadingProviderIds.has(candidate.id)) + : Boolean(activeProvider && loadingProviderIds.has(activeProvider.id)); useLayoutEffect(() => { if (!open) return; const id = listItems[activeIndex]?.id; @@ -360,6 +392,15 @@ export function HarnessModelPicker({ ); @@ -398,11 +439,16 @@ export function HarnessModelPicker({ type="button" role="tab" className={"rail-btn" + (p.id === activeProvider?.id ? " active" : "")} - aria-label={p.label} + aria-label={loadingProviderIds.has(p.id) ? `${p.label}: ${loadingLabel}` : p.label} aria-selected={p.id === activeProvider?.id} onClick={() => setRailProvider(p.id)} > + {loadingProviderIds.has(p.id) ? ( + + ) : null} ))} @@ -443,7 +489,23 @@ export function HarnessModelPicker({ spellCheck={false} /> -
+ {isListLoading && listItems.length ? ( +
+ + {loadingLabel} +
+ ) : null} +
+ + {loadingLabel} +
+
{listItems.length ? listItems.map((item, i) => { const row = (
@@ -490,6 +552,7 @@ export function StatusRow({ provider, providers, allProviderOptions, + loadingProviderIds, onProviderModelChange, cwd, onCwdChange, @@ -504,6 +567,7 @@ export function StatusRow({ provider: string; providers?: Provider[]; allProviderOptions?: Record; + loadingProviderIds?: ReadonlySet; onProviderModelChange?: (provider: string, model: string) => void; cwd: string; onCwdChange?: (v: string) => void; @@ -515,12 +579,13 @@ export function StatusRow({ trailing?: ReactNode; running?: boolean; }) { - const effortLike = options.filter((o) => o.role === "effort" && o.kind === "select" && !isOffLikeValue(String(o.value))); - const context = options.find((o) => o.id === "context" && o.kind === "select"); - const fast = options.find((o) => o.id === "fastMode" && o.kind === "toggle"); - const approval = options.find((o) => o.role === "approval" && o.kind === "toggle"); - const mode = options.find((o) => (o.id === "mode" || o.id === "permissionMode") && o.kind === "select"); - const overflow = options.filter((o) => !isInlineOption(o)); + const resolvedOptions = optionsForSelectedModel(options); + const effortLike = resolvedOptions.filter((o) => o.role === "effort" && o.kind === "select" && !isOffLikeValue(String(o.value))); + const context = resolvedOptions.find((o) => o.id === "context" && o.kind === "select"); + const fast = resolvedOptions.find((o) => o.id === "fastMode" && o.kind === "toggle"); + const approval = resolvedOptions.find((o) => o.role === "approval" && o.kind === "toggle"); + const mode = resolvedOptions.find((o) => (o.id === "mode" || o.id === "permissionMode") && o.kind === "select"); + const overflow = resolvedOptions.filter((o) => !isInlineOption(o)); const modeLabel = mode && !["", "default", "build"].includes(String(mode.value)) ? prettyValue(mode) : ""; const providerInfo = providers?.find((p) => p.id === provider) ?? { id: provider, label: provider }; return ( @@ -530,8 +595,9 @@ export function StatusRow({ setOpenOptionId(open ? "modelPicker" : null)} onSelect={onProviderModelChange} @@ -539,6 +605,24 @@ export function StatusRow({ /> ) : } + {loadingProviderIds?.has(provider) ? ( + + + {effortLoadingLabel()} + + ) : null} + {!loadingProviderIds?.has(provider) ? effortLike.map((option) => ( + } + choiceIcon={(value) => } + label={`${option.label}: ${prettyValue(option)}`} + onChange={onChange} + open={openOptionId === option.id} + onOpenChange={(open) => setOpenOptionId(open ? option.id : null)} + /> + )) : null} {fast ? (