Show model loading and model-specific effort controls (#10098)

* test: cover pending model catalogs in shared picker

* Show model catalog loading in shared picker

* test: expose buried model loading state

* Make pending model picker unmistakable

* test: require model-specific effort picker

* Use model-specific effort metadata

* test: require native model effort picker

* test: isolate native effort picker regression

* test: isolate iOS UI test plan

* feat: add native model effort picker

* test: verify native effort choices

* test: cover all native effort choices

* test: require shared readable picker scroller

* fix: keep task pickers readable in one scroller

* test: measure visible picker labels accurately

* test: cover default model effort picker

* fix: source efforts for the default model

* fix: keep default efforts through draft restore

* test: cover unpinned default model efforts

* test: define semantic OpenCode effort ordering

* fix: order OpenCode effort variants semantically

* test: cover task model discovery failures

* fix: surface task model discovery errors

* fix: keep model discovery errors readable

* fix: resolve provider loading review findings
This commit is contained in:
Abdulaziz Albahar
2026-08-18 13:58:00 -07:00
committed by GitHub
parent 7589f52b81
commit 786a35d099
50 changed files with 2232 additions and 308 deletions
+3
View File
@@ -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
@@ -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
}
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 else {
throw MobileShellConnectionError.invalidResponse
}
let rawEfforts = rawModel["efforts"] as? [[String: Any]] ?? []
var seenEffortIDs: Set<String> = []
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,
efforts: efforts,
defaultEffortID: rawModel["default_effort_id"] as? String
)
}
var models: [MobileTaskAgentModel] = []
models.reserveCapacity(rawModels.count)
var seenIDs: Set<String> = []
for rawModel in rawModels {
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 {
let model = try parseModel(rawModel)
guard seenIDs.insert(model.id).inserted else {
throw MobileShellConnectionError.invalidResponse
}
models.append(MobileTaskAgentModel(
id: id,
displayName: displayName
))
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(
@@ -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<String> = []
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?
}
}
@@ -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",
@@ -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(
@@ -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..<boundary, " \(quotedID)"))
edits.append((boundary..<boundary, " \(quotedValue)"))
break
}
if let value = Self.tokenRange(in: command, from: token.upperBound),
@@ -92,20 +128,20 @@ public enum MobileTaskAgentProvider: String, CaseIterable, Sendable {
if let valueBoundary = Self.unquotedCommandBoundaryIndex(in: valueText) {
// `--model old;`: replace only the value before the
// separator so no stale positional argument remains.
edits.append((value.lowerBound..<valueBoundary, quotedID))
edits.append((value.lowerBound..<valueBoundary, quotedValue))
break
}
edits.append((value, quotedID))
edits.append((value, quotedValue))
searchStart = value.upperBound
} else {
// Dangling flag (at the end of the simple command):
// supply the value right after the flag token.
edits.append((token.upperBound..<token.upperBound, " \(quotedID)"))
edits.append((token.upperBound..<token.upperBound, " \(quotedValue)"))
}
continue
}
if let spelling = modelFlagSpellings.first(where: { word.hasPrefix("\($0)=") }) {
edits.append((token.lowerBound..<word.endIndex, "\(spelling)=\(quotedID)"))
if let spelling = flagSpellings.first(where: { word.hasPrefix("\($0)=") }) {
edits.append((token.lowerBound..<word.endIndex, "\(spelling)=\(quotedValue)"))
}
if boundary != nil { break }
}
@@ -117,8 +153,53 @@ public enum MobileTaskAgentProvider: String, CaseIterable, Sendable {
return replaced
}
let flag = modelFlagSpellings[0]
return "\(command[..<firstToken.upperBound]) \(flag) \(quotedID)\(command[firstToken.upperBound...])"
let flag = flagSpellings[0]
return "\(command[..<firstToken.upperBound]) \(flag) \(quotedValue)\(command[firstToken.upperBound...])"
}
private func applyingCodexEffort(_ effortID: String, to command: String) -> 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<String.Index>, replacement: String)] = []
var searchStart = firstToken.upperBound
while let token = Self.tokenRange(in: command, from: searchStart) {
if command[searchStart..<token.lowerBound].contains(where: \.isNewline) { break }
searchStart = token.upperBound
let text = command[token]
if text == "--" || text.hasPrefix("#") { break }
let boundary = Self.unquotedCommandBoundaryIndex(in: text)
let word = boundary.map { text[..<$0] } ?? text
if word.hasPrefix("--config=model_reasoning_effort=") {
edits.append((token.lowerBound..<word.endIndex, "--config=\(replacement)"))
}
if boundary != nil { break }
guard text == "-c" || text == "--config" else { continue }
guard let value = Self.tokenRange(in: command, from: token.upperBound),
!command[token.upperBound..<value.lowerBound].contains(where: \.isNewline)
else { break }
let valueText = command[value]
let valueBoundary = Self.unquotedCommandBoundaryIndex(in: valueText)
let valueWord = valueBoundary.map { valueText[..<$0] } ?? valueText
if valueWord.hasPrefix("model_reasoning_effort=") {
edits.append((value.lowerBound..<valueWord.endIndex, replacement))
}
searchStart = value.upperBound
if valueBoundary != nil { break }
}
if !edits.isEmpty {
var replaced = command
for edit in edits.reversed() {
replaced.replaceSubrange(edit.range, with: edit.replacement)
}
return replaced
}
return "\(command[..<firstToken.upperBound]) -c \(replacement)\(command[firstToken.upperBound...])"
}
private static func shellQuoted(_ value: String) -> 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
}
}
@@ -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
}
@@ -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
@@ -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.
@@ -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
}
}
@@ -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
@@ -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",
@@ -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")
}
}
@@ -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
@@ -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: "Couldnt 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: "Couldnt 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
@@ -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,
@@ -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: "Couldnt 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: "Couldnt 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
@@ -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<Void, Never>?
@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<Duration>
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<Duration> = 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()
}
@@ -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
}
}
@@ -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
}
}
@@ -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<String> = []
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] {
@@ -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
@@ -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(
@@ -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(
@@ -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)
}
}
+58 -23
View File
@@ -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<string | null> } | 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<string, ClaudeModelMeta>();
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<ClaudeState, "model" | "modelChoices" | "modelMet
}
function modelMeta(st: Pick<ClaudeState, "model" | "modelMeta">): 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<ClaudeState, "model" | "modelMeta" | "effort" | "fastMode">): { 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<string, typeof raw[number]>();
const extendedByBase = new Map<string, typeof raw[number]>();
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<string | null> {
const now = Date.now();
if (claudeVersionCache?.promise) return claudeVersionCache.promise;
+18 -10
View File
@@ -46,7 +46,6 @@ interface CodexState {
let shared: AppServer | null = null;
let sharedStarting: Promise<AppServer> | 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 };
}
+49 -27
View File
@@ -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<PiState, "model" | "modelChoices" | "thinking">): 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<PiState, "model" | "modelChoices" | "thinking">): { 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[] {
+1 -1
View File
@@ -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",
+46 -2
View File
@@ -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;
+31 -2
View File
@@ -615,12 +615,19 @@ async function sanitizeStartOptions(provider: string, cwd: string, raw: Record<s
function filterOptions(raw: Record<string, OptionValue>, catalog: SessionOption[]): Record<string, OptionValue> {
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<string, OptionValue> = {};
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;
+14 -8
View File
@@ -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() {
/>
</div>
</div>
{helpOpen ? <ShortcutOverlay provider={session?.provider ?? "agent"} options={options} running={running} ctrlJ={ctrlJ} onClose={() => setHelpOpen(false)} /> : null}
{helpOpen ? <ShortcutOverlay provider={session?.provider ?? "agent"} options={resolvedOptions} running={running} ctrlJ={ctrlJ} onClose={() => setHelpOpen(false)} /> : null}
</section>
);
}
+6
View File
@@ -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}
+100 -28
View File
@@ -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<string> = 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<string, SessionOption[]>;
loadingProviderIds: ReadonlySet<string>;
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<HTMLInputElement>(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({
<button ref={triggerRef} type="button" className={"row-control provider-model-trigger select-trigger" + (running ? " provider-running" : "")} aria-label="Switch harness or model">
<ProviderIcon provider={currentProvider} />
<span className="row-value">{label}</span>
<span
className="model-picker-trigger-loading"
data-visible={isCurrentProviderLoading}
role={isCurrentProviderLoading ? "status" : undefined}
aria-label={isCurrentProviderLoading ? loadingLabel : undefined}
aria-hidden={isCurrentProviderLoading ? undefined : true}
>
<PinwheelSpinner size={11} />
</span>
<span className="chev"><Chevron /></span>
</button>
);
@@ -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)}
>
<ProviderIcon provider={p} />
{loadingProviderIds.has(p.id) ? (
<span className="rail-loading-indicator" aria-hidden="true">
<PinwheelSpinner size={8} />
</span>
) : null}
</button>
</HintTooltip>
))}
@@ -443,7 +489,23 @@ export function HarnessModelPicker({
spellCheck={false}
/>
</div>
<div className="model-picker-list" id="model-picker-list" role="listbox" aria-label="Models" ref={listRef}>
{isListLoading && listItems.length ? (
<div className="model-picker-loading" role="status" aria-live="polite">
<PinwheelSpinner size={listItems.length ? 12 : 20} />
<span>{loadingLabel}</span>
</div>
) : null}
<div
className="model-picker-loading model-picker-loading-full"
data-visible={isListLoading && !listItems.length}
role={isListLoading && !listItems.length ? "status" : undefined}
aria-live={isListLoading && !listItems.length ? "polite" : undefined}
aria-hidden={isListLoading && !listItems.length ? undefined : true}
>
<PinwheelSpinner size={20} />
<span>{loadingLabel}</span>
</div>
<div className="model-picker-list" id="model-picker-list" role="listbox" aria-label="Models" aria-busy={isListLoading} ref={listRef}>
{listItems.length ? listItems.map((item, i) => {
const row = (
<button
@@ -471,9 +533,9 @@ export function HarnessModelPicker({
return item.disabled && item.disabledReason
? <HintTooltip key={item.id} label={item.disabledReason}><span className="model-row-tooltip-wrap">{row}</span></HintTooltip>
: row;
}) : (
}) : !isListLoading ? (
<div className="model-picker-empty">No models found</div>
)}
) : null}
</div>
</div>
</div>
@@ -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<string, SessionOption[]>;
loadingProviderIds?: ReadonlySet<string>;
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({
<HarnessModelPicker
provider={provider}
providers={providers}
options={options}
options={resolvedOptions}
allProviderOptions={allProviderOptions ?? {}}
loadingProviderIds={loadingProviderIds ?? noLoadingProviderIds}
open={openOptionId === "modelPicker"}
onOpenChange={(open) => setOpenOptionId(open ? "modelPicker" : null)}
onSelect={onProviderModelChange}
@@ -539,6 +605,24 @@ export function StatusRow({
/>
)
: <StaticProvider provider={providerInfo} running={running} />}
{loadingProviderIds?.has(provider) ? (
<span className="effort-picker-loading" role="status" aria-label={effortLoadingLabel()}>
<PinwheelSpinner size={11} />
<span>{effortLoadingLabel()}</span>
</span>
) : null}
{!loadingProviderIds?.has(provider) ? effortLike.map((option) => (
<InlineSelect
key={option.id}
option={option}
icon={<BarsIcon filled={effortFill(option)} />}
choiceIcon={(value) => <BarsIcon filled={effortFill(option, value)} />}
label={`${option.label}: ${prettyValue(option)}`}
onChange={onChange}
open={openOptionId === option.id}
onOpenChange={(open) => setOpenOptionId(open ? option.id : null)}
/>
)) : null}
{fast ? (
<HintTooltip label={optionTooltip(fast)} action="toggle-fast">
<button
@@ -552,18 +636,6 @@ export function StatusRow({
</button>
</HintTooltip>
) : null}
{effortLike.map((option) => (
<InlineSelect
key={option.id}
option={option}
icon={<BarsIcon filled={effortFill(option)} />}
choiceIcon={(value) => <BarsIcon filled={effortFill(option, value)} />}
label={prettyValue(option)}
onChange={onChange}
open={openOptionId === option.id}
onOpenChange={(open) => setOpenOptionId(open ? option.id : null)}
/>
))}
{context ? (
<InlineSelect
option={context}
+39 -1
View File
@@ -71,8 +71,46 @@ export function sanitizeStartOptions(dirty: Record<string, OptionValue>, options
return out;
}
export function optionsForSelectedModel(options: SessionOption[]): SessionOption[] {
const model = options.find((option) => option.id === "model" && option.kind === "select");
if (!model) return options;
const selected = model.choices?.find((choice) => choice.value === model.value);
const existingEffort = options.find((option) => option.role === "effort" && option.kind === "select");
const withoutEffort = options.filter((option) => option !== existingEffort);
const choices = selected?.efforts;
if (!existingEffort || !choices?.length) return withoutEffort;
const requested = String(existingEffort?.value ?? "");
const preferred = choices.some((choice) => choice.value === requested)
? requested
: choices.some((choice) => choice.value === selected.defaultEffort)
? selected.defaultEffort!
: choices[0]!.value;
const effort: SessionOption = {
id: existingEffort.id,
label: existingEffort.label,
kind: "select",
role: "effort",
value: preferred,
choices,
disabled: existingEffort.disabled && Boolean(existingEffort.choices?.length),
description: existingEffort.description,
};
const modelIndex = withoutEffort.indexOf(model);
return [
...withoutEffort.slice(0, modelIndex + 1),
effort,
...withoutEffort.slice(modelIndex + 1),
];
}
export function withLocalValues(options: SessionOption[], local: Record<string, OptionValue>): SessionOption[] {
return options.map((o) => {
const modelFirst = options.map((o) => {
if (o.id !== "model" || !Object.prototype.hasOwnProperty.call(local, o.id)) return o;
const value = local[o.id];
return optionAcceptsValue(o, value) ? { ...o, value } : o;
});
return optionsForSelectedModel(modelFirst).map((o) => {
if (o.id === "model") return o;
if (!Object.prototype.hasOwnProperty.call(local, o.id)) return o;
const value = local[o.id];
return optionAcceptsValue(o, value) ? { ...o, value } : o;
+7 -7
View File
@@ -9,10 +9,10 @@ export const galleryProviders: Provider[] = [
];
const modelChoices = [
{ value: "claude-fable-5", label: "Claude Fable 5", description: "Latest fast model" },
{ value: "claude-opus-4-8", label: "Claude Opus 4.8", description: "Largest reasoning model" },
{ value: "claude-fable-5", label: "Claude Fable 5", description: "Latest fast model", efforts: ["low", "medium"].map((value) => ({ value, label: value })), defaultEffort: "low" },
{ value: "claude-opus-4-8", label: "Claude Opus 4.8", description: "Largest reasoning model", efforts: ["high", "xhigh", "max"].map((value) => ({ value, label: value })), defaultEffort: "high" },
{ value: "claude-opus-4-7", label: "Claude Opus 4.7", disabled: true, disabledReason: "Upgrade Claude Code to use Opus 4.7" },
{ value: "claude-sonnet-5", label: "Claude Sonnet 5", description: "Default balanced model" },
{ value: "claude-sonnet-5", label: "Claude Sonnet 5", description: "Default balanced model", efforts: ["low", "medium", "high", "xhigh"].map((value) => ({ value, label: value })), defaultEffort: "high" },
{ value: "claude-haiku-4-5", label: "Claude Haiku 4.5", description: "Fast small model" },
];
@@ -27,9 +27,9 @@ export const galleryOptions: Record<string, SessionOption[]> = {
],
codex: [
{ id: "model", label: "Model", kind: "select", value: "gpt-5.4-codex", choices: [
{ value: "gpt-5.5", label: "GPT-5.5", description: "Frontier model" },
{ value: "gpt-5.4-codex", label: "GPT-5.4 Codex", description: "Best coding model" },
{ value: "gpt-5.4-mini", label: "GPT-5.4 Mini", description: "Fast and cheap" },
{ value: "gpt-5.5", label: "GPT-5.5", description: "Frontier model", efforts: ["low", "medium", "high", "xhigh"].map((value) => ({ value, label: value })), defaultEffort: "medium" },
{ value: "gpt-5.4-codex", label: "GPT-5.4 Codex", description: "Best coding model", efforts: ["medium", "high", "xhigh"].map((value) => ({ value, label: value })), defaultEffort: "medium" },
{ value: "gpt-5.4-mini", label: "GPT-5.4 Mini", description: "Fast and cheap", efforts: ["low", "medium"].map((value) => ({ value, label: value })), defaultEffort: "low" },
{ value: "o4-preview", label: "O4 Preview", disabled: true, disabledReason: "Unavailable for this account" },
] },
{ id: "effort", label: "Effort", kind: "select", role: "effort", value: "medium", choices: ["low", "medium", "high", "xhigh"].map((v) => ({ value: v, label: v === "xhigh" ? "Extra high" : v[0].toUpperCase() + v.slice(1) })) },
@@ -57,7 +57,7 @@ export const galleryOptions: Record<string, SessionOption[]> = {
],
pi: [
{ id: "model", label: "Model", kind: "select", value: "openai/gpt-5.4", choices: [
{ value: "openai/gpt-5.4", label: "OpenAI / GPT-5.4" },
{ value: "openai/gpt-5.4", label: "OpenAI / GPT-5.4", efforts: ["minimal", "low", "medium", "high"].map((value) => ({ value, label: value })), defaultEffort: "medium" },
{ value: "anthropic/claude-sonnet-5", label: "Anthropic / Claude Sonnet 5" },
{ value: "google/gemini-3-flash-preview", label: "Google / Gemini 3 Flash Preview" },
] },
+21 -7
View File
@@ -20,6 +20,8 @@ import {
import { groupTurns } from "./turns";
const cwd = "/Users/lawrence/fun/cmuxterm-hq/worktrees/feat-agent-chat-ui/agent-chat";
const noLoadingProviders = new Set<string>();
const codexModelsLoading = new Set(["codex"]);
function setOption(options: SessionOption[], id: string, value: OptionValue): SessionOption[] {
return options.map((o) => o.id === id ? { ...o, value } : o);
@@ -37,18 +39,21 @@ function Section({ id, title, children }: { id: string; title: string; children:
function DemoStatusRow({
provider,
running = false,
modelsLoading = false,
}: {
provider: string;
running?: boolean;
modelsLoading?: boolean;
}) {
const [options, setOptions] = useState(() => galleryOptions[provider] ?? []);
const [options, setOptions] = useState(() => modelsLoading ? [] : galleryOptions[provider] ?? []);
const [openOptionId, setOpenOptionId] = useState<string | null>(null);
return (
<div className="gallery-card compact">
<StatusRow
provider={provider}
providers={galleryProviders}
allProviderOptions={galleryOptions}
allProviderOptions={modelsLoading ? {} : galleryOptions}
loadingProviderIds={modelsLoading ? new Set([provider]) : noLoadingProviders}
onProviderModelChange={(nextProvider, model) => console.log("gallery model", nextProvider, model)}
cwd={cwd}
options={options}
@@ -61,8 +66,11 @@ function DemoStatusRow({
);
}
function ComposerMock({ state }: { state: "idle" | "starting" | "draft" | "error" }) {
function ComposerMock({ state }: { state: "idle" | "starting" | "draft" | "error" | "loading-models" }) {
const provider = state === "error" ? "claude" : "codex";
const modelsLoading = state === "loading-models";
const options = modelsLoading ? [] : galleryOptions[provider];
const [openOptionId, setOpenOptionId] = useState<string | null>(null);
return (
<div className="gallery-composer-wrap">
<div id="composer-card">
@@ -78,13 +86,14 @@ function ComposerMock({ state }: { state: "idle" | "starting" | "draft" | "error
<StatusRow
provider={provider}
providers={galleryProviders}
allProviderOptions={galleryOptions}
allProviderOptions={modelsLoading ? {} : galleryOptions}
loadingProviderIds={modelsLoading ? codexModelsLoading : noLoadingProviders}
onProviderModelChange={(p, model) => console.log("gallery composer pick", p, model)}
cwd={cwd}
options={galleryOptions[provider]}
options={options}
onChange={(id, value) => console.log("gallery composer option", id, value)}
openOptionId={null}
setOpenOptionId={() => {}}
openOptionId={openOptionId}
setOpenOptionId={setOpenOptionId}
trailing={(
<button className="send" type="button" aria-label="Start" disabled={state === "starting"}>
{state === "starting" ? <PinwheelSpinner size={14} /> : <span aria-hidden></span>}
@@ -240,6 +249,10 @@ export function GalleryApp() {
<DemoStatusRow provider={provider.id} running />
</div>
))}
<div>
<div className="gallery-label">Codex models loading, running</div>
<DemoStatusRow provider="codex" running modelsLoading />
</div>
</div>
</Section>
@@ -247,6 +260,7 @@ export function GalleryApp() {
<div className="gallery-stack">
<div><div className="gallery-label">Idle</div><ComposerMock state="idle" /></div>
<div><div className="gallery-label">Draft</div><ComposerMock state="draft" /></div>
<div><div className="gallery-label">Models loading</div><ComposerMock state="loading-models" /></div>
<div><div className="gallery-label">Invalid cwd error</div><ComposerMock state="error" /></div>
</div>
</Section>
+11
View File
@@ -69,6 +69,17 @@ export function providerOptionMap(providers: Provider[], providerOptions: Record
]));
}
export function loadingProviderOptionIds(
providers: Provider[],
providerOptions: Record<string, SessionOption[]>,
): ReadonlySet<string> {
return new Set(
providers
.filter((provider) => provider.installed !== false && !Object.hasOwn(providerOptions, provider.id))
.map((provider) => provider.id),
);
}
export function useCwdValidation(
ready: boolean,
connectionEpoch: number,
+9 -1
View File
@@ -20,7 +20,15 @@ export type AgentEvent =
export type OptionKind = "select" | "toggle";
export type OptionValue = string | boolean;
export type CommandTrigger = "/" | "$" | "@";
export interface OptionChoice { value: string; label: string; description?: string; disabled?: boolean; disabledReason?: string; }
export interface OptionChoice {
value: string;
label: string;
description?: string;
disabled?: boolean;
disabledReason?: string;
efforts?: OptionChoice[];
defaultEffort?: string;
}
export interface SessionOption {
id: string;
label: string;
+2
View File
@@ -80,6 +80,8 @@ test("catalog validation, provider merges, persistence, and ETag", async () => {
const catchModel = catchOptions.find((option) => option.id === "model");
expect(catchModel?.value).toBe("gpt-new");
expect(catchModel?.choices?.map((choice) => choice.value)).toContain("gpt-new");
expect(catchModel?.choices?.find((choice) => choice.value === "gpt-new")?.efforts?.map((effort) => effort.value)).toEqual(["xhigh"]);
expect(catchModel?.choices?.find((choice) => choice.value === "gpt-new")?.defaultEffort).toBe("xhigh");
expect(selectEnabledModel("gated", [
{ id: "gated", disabled: true },
@@ -0,0 +1,156 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { HarnessModelPicker, StatusRow, providerModelItemsForState } from "../src/components/StatusRow";
import { withLocalValues } from "../src/components/options";
import { loadingProviderOptionIds } from "../src/hooks/useCatalogs";
import type { Provider, SessionOption } from "../src/session";
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { documentElement: {} },
});
Object.defineProperty(globalThis, "getComputedStyle", {
configurable: true,
value: () => ({ getPropertyValue: () => "" }),
});
const provider: Provider = { id: "codex", label: "Codex", installed: true };
const unavailableProvider: Provider = { id: "missing", label: "Missing", installed: false };
const pendingProviderIds = loadingProviderOptionIds([provider, unavailableProvider], {});
if (!pendingProviderIds.has(provider.id) || pendingProviderIds.has(unavailableProvider.id)) {
throw new Error(`only installed providers without a response should be loading, got ${[...pendingProviderIds]}`);
}
const emptyResponseProviderIds = loadingProviderOptionIds([provider], { [provider.id]: [] });
if (emptyResponseProviderIds.has(provider.id)) {
throw new Error("an empty model response should be treated as loaded");
}
function renderPicker(running: boolean, options: SessionOption[], loading: boolean): string {
return renderToStaticMarkup(React.createElement(HarnessModelPicker, {
provider: provider.id,
providers: [provider],
options,
allProviderOptions: options.length ? { [provider.id]: options } : {},
loadingProviderIds: loading ? new Set([provider.id]) : new Set<string>(),
open: false,
onOpenChange: () => {},
onSelect: () => {},
running,
}));
}
for (const [surface, running] of [["task composer", false], ["running provider control", true]] as const) {
const markup = renderPicker(running, [], true);
if (!markup.includes('role="status"') || !markup.includes('aria-label="Loading models"')) {
throw new Error(`${surface}: expected an accessible model-loading indicator, got ${markup}`);
}
if (!markup.includes("pinwheel-spinner")) {
throw new Error(`${surface}: expected the model-loading spinner, got ${markup}`);
}
}
const fallbackOptions: SessionOption[] = [{
id: "model",
label: "Model",
kind: "select",
value: "fallback-model",
choices: [{ value: "fallback-model", label: "Fallback model" }],
}];
const pendingItems = providerModelItemsForState(provider, provider.id, fallbackOptions, true);
if (pendingItems.length) {
throw new Error(`pending picker should not bury its loading state under fallback model rows, got ${JSON.stringify(pendingItems)}`);
}
const modelSpecificOptions = [{
id: "model",
label: "Model",
kind: "select",
value: "fast-model",
choices: [
{
value: "fast-model",
label: "Fast model",
efforts: [{ value: "low", label: "Low" }],
defaultEffort: "low",
},
{
value: "deep-model",
label: "Deep model",
efforts: [{ value: "high", label: "High" }, { value: "xhigh", label: "Extra high" }],
defaultEffort: "high",
},
{ value: "no-effort-model", label: "No effort model" },
],
}, {
id: "effort",
label: "Effort",
kind: "select",
role: "effort",
value: "low",
choices: [{ value: "low", label: "Low" }],
}] as SessionOption[];
const deepModelOptions = withLocalValues(modelSpecificOptions, { model: "deep-model", effort: "low" });
const deepEffort = deepModelOptions.find((option) => option.role === "effort");
if (deepEffort?.value !== "high" || deepEffort.choices?.map((choice) => choice.value).join(",") !== "high,xhigh") {
throw new Error(`effort picker should use the selected model's reported choices and default, got ${JSON.stringify(deepEffort)}`);
}
const noEffortOptions = withLocalValues(modelSpecificOptions, { model: "no-effort-model" });
if (noEffortOptions.some((option) => option.role === "effort")) {
throw new Error(`model without reported effort metadata should not inherit provider-wide choices, got ${JSON.stringify(noEffortOptions)}`);
}
const orderedStatusMarkup = renderToStaticMarkup(React.createElement(StatusRow, {
provider: provider.id,
providers: [provider],
allProviderOptions: { [provider.id]: modelSpecificOptions },
loadingProviderIds: new Set<string>(),
onProviderModelChange: () => {},
cwd: "/tmp",
options: [
...modelSpecificOptions,
{ id: "fastMode", label: "Fast", kind: "toggle", value: false },
],
onChange: () => {},
openOptionId: null,
setOpenOptionId: () => {},
}));
const effortIndex = orderedStatusMarkup.indexOf('aria-label="Effort"');
const fastIndex = orderedStatusMarkup.indexOf('aria-label="Fast"');
if (effortIndex < 0 || fastIndex < 0 || effortIndex > fastIndex) {
throw new Error(`effort picker should appear immediately after the model picker and before fast mode, got ${orderedStatusMarkup}`);
}
const loadingStatusMarkup = renderToStaticMarkup(React.createElement(StatusRow, {
provider: provider.id,
providers: [provider],
allProviderOptions: { [provider.id]: modelSpecificOptions },
loadingProviderIds: new Set([provider.id]),
onProviderModelChange: () => {},
cwd: "/tmp",
options: modelSpecificOptions,
onChange: () => {},
openOptionId: null,
setOpenOptionId: () => {},
}));
if (!loadingStatusMarkup.includes('aria-label="Loading effort"') || loadingStatusMarkup.includes('aria-label="Effort"')) {
throw new Error(`loading provider should show the effort loading state without a stale effort picker, got ${loadingStatusMarkup}`);
}
const loadedOptions: SessionOption[] = [{
id: "model",
label: "Model",
kind: "select",
value: "gpt-5.6-sol",
choices: [{ value: "gpt-5.6-sol", label: "GPT-5.6 Sol" }],
}];
const loadedMarkup = renderPicker(false, loadedOptions, false);
if (loadedMarkup.includes('aria-label="Loading models"') || !loadedMarkup.includes('data-visible="false"')) {
throw new Error(`loaded picker should keep its animated loading indicator hidden and inaccessible, got ${loadedMarkup}`);
}
if (!loadedMarkup.includes("GPT-5.6 Sol")) {
throw new Error(`loaded picker should show the selected model, got ${loadedMarkup}`);
}
console.log("model picker loading indicator: OK");
+2
View File
@@ -25,6 +25,8 @@ export interface OptionChoice {
description?: string;
disabled?: boolean;
disabledReason?: string;
efforts?: OptionChoice[];
defaultEffort?: string;
}
export interface SessionOption {
+4
View File
@@ -16,6 +16,7 @@
8B8A10042DF5000000A66F90 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8B8A101B2DF5000000A66F90 /* Localizable.xcstrings */; };
8B8A10E02DF5000000A66F90 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8B8A10E12DF5000000A66F90 /* PrivacyInfo.xcprivacy */; };
8B8A10052DF5000000A66F90 /* cmuxUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A101C2DF5000000A66F90 /* cmuxUITests.swift */; };
A7EFF0012F00000000A66F90 /* TaskComposerEffortPickerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7EFF0022F00000000A66F90 /* TaskComposerEffortPickerUITests.swift */; };
8B8A10252DF5000000A66F90 /* TerminalThemeParityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A10262DF5000000A66F90 /* TerminalThemeParityUITests.swift */; };
8B8A10302DF5000000A66F90 /* PushReadinessUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A10312DF5000000A66F90 /* PushReadinessUITests.swift */; };
8B8A10E22DF5000000A66F90 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8A10E32DF5000000A66F90 /* SnapshotHelper.swift */; };
@@ -57,6 +58,7 @@
8B8A101B2DF5000000A66F90 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
8B8A10E12DF5000000A66F90 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
8B8A101C2DF5000000A66F90 /* cmuxUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxUITests.swift; sourceTree = "<group>"; };
A7EFF0022F00000000A66F90 /* TaskComposerEffortPickerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskComposerEffortPickerUITests.swift; sourceTree = "<group>"; };
8B8A10262DF5000000A66F90 /* TerminalThemeParityUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemeParityUITests.swift; sourceTree = "<group>"; };
8B8A10312DF5000000A66F90 /* PushReadinessUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushReadinessUITests.swift; sourceTree = "<group>"; };
8B8A10E32DF5000000A66F90 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = "<group>"; };
@@ -106,6 +108,7 @@
isa = PBXGroup;
children = (
8B8A101C2DF5000000A66F90 /* cmuxUITests.swift */,
A7EFF0022F00000000A66F90 /* TaskComposerEffortPickerUITests.swift */,
8B8A10262DF5000000A66F90 /* TerminalThemeParityUITests.swift */,
8B8A10312DF5000000A66F90 /* PushReadinessUITests.swift */,
8B8A10E52DF5000000A66F90 /* SnapshotUITests.swift */,
@@ -288,6 +291,7 @@
buildActionMask = 2147483647;
files = (
8B8A10052DF5000000A66F90 /* cmuxUITests.swift in Sources */,
A7EFF0012F00000000A66F90 /* TaskComposerEffortPickerUITests.swift in Sources */,
8B8A10252DF5000000A66F90 /* TerminalThemeParityUITests.swift in Sources */,
8B8A10302DF5000000A66F90 /* PushReadinessUITests.swift in Sources */,
8B8A10E42DF5000000A66F90 /* SnapshotUITests.swift in Sources */,
@@ -33,6 +33,9 @@
reference = "container:cmux/cmux.xctestplan"
default = "YES">
</TestPlanReference>
<TestPlanReference
reference = "container:cmux/cmux-ui.xctestplan">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
+83
View File
@@ -10814,6 +10814,27 @@
}
}
},
"mobile.taskComposer.agent.error.claudeUnavailable": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Claude unavailable" } },
"ja": { "stringUnit": { "state": "translated", "value": "Claudeを利用できません" } }
}
},
"mobile.taskComposer.agent.error.codexUnavailable": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Codex unavailable" } },
"ja": { "stringUnit": { "state": "translated", "value": "Codexを利用できません" } }
}
},
"mobile.taskComposer.agent.error.openCodeUnavailable": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "OpenCode unavailable" } },
"ja": { "stringUnit": { "state": "translated", "value": "OpenCodeを利用できません" } }
}
},
"mobile.taskComposer.button.accessibilityHint": {
"extractionState": "manual",
"localizations": {
@@ -12208,6 +12229,47 @@
}
}
},
"mobile.taskComposer.effort": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Effort"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "思考レベル"
}
}
}
},
"mobile.taskComposer.effort.error.queryFailed": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Couldnt load efforts" } },
"ja": { "stringUnit": { "state": "translated", "value": "思考レベルを読み込めませんでした" } }
}
},
"mobile.taskComposer.effort.accessibilityHint": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Chooses the effort supported by the selected model."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "選択したモデルが対応する思考レベルを選択します。"
}
}
}
},
"mobile.taskComposer.model": {
"extractionState": "manual",
"localizations": {
@@ -12276,6 +12338,27 @@
}
}
},
"mobile.taskComposer.model.error.providerUnavailable": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Agent models unavailable" } },
"ja": { "stringUnit": { "state": "translated", "value": "エージェントのモデルを利用できません" } }
}
},
"mobile.taskComposer.model.error.queryFailed": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Couldnt load models" } },
"ja": { "stringUnit": { "state": "translated", "value": "モデルを読み込めませんでした" } }
}
},
"mobile.taskComposer.model.error.hostUnavailable": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Mac unavailable" } },
"ja": { "stringUnit": { "state": "translated", "value": "Macを利用できません" } }
}
},
"mobile.taskComposer.openingShell": {
"extractionState": "manual",
"localizations": {
+28
View File
@@ -0,0 +1,28 @@
{
"configurations" : [
{
"id" : "E8F1BB38-A6DC-4F55-AE06-F3374BD1EED2",
"name" : "Test Scheme Action",
"options" : {
}
}
],
"defaultOptions" : {
"targetForVariableExpansion" : {
"containerPath" : "container:cmux-ios.xcodeproj",
"identifier" : "8B41F6442DEDD0D5001A66F9",
"name" : "cmux"
}
},
"testTargets" : [
{
"target" : {
"containerPath" : "container:cmux-ios.xcodeproj",
"identifier" : "8B41F65B2DEDD0D6001A66F9",
"name" : "cmuxUITests"
}
}
],
"version" : 1
}
@@ -0,0 +1,106 @@
import XCTest
final class TaskComposerEffortPickerUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testEffortPickerFollowsModelPicker() {
let app = XCUIApplication()
app.launchArguments += [
"-AppleLanguages",
"(en)",
"-AppleLocale",
"en_US",
]
app.launchEnvironment["CMUX_UITEST_MOCK_DATA"] = "0"
app.launchEnvironment["CMUX_UITEST_TASK_COMPOSER_PREVIEW"] = "1"
app.launchEnvironment["CMUX_UITEST_TASK_MODEL_CATALOG_JSON"] = #"{"schemaVersion":1,"updatedAt":"2026-08-14T00:00:00Z","providers":{"claude":{"defaultModel":"claude-opus","models":[{"id":"claude-opus","label":"Claude Opus 4.8","efforts":[{"value":"low","label":"Low"},{"value":"medium","label":"Medium"},{"value":"high","label":"High"}],"defaultEffort":"medium"}]}}}"#
app.launch()
defer { app.terminate() }
let scroller = app.scrollViews["MobileTaskComposerPillScroller"]
XCTAssertTrue(scroller.waitForExistence(timeout: 8))
let agent = scroller.buttons["MobileTaskComposerAgentPill"]
let model = scroller.buttons["MobileTaskComposerModelPill"]
XCTAssertTrue(model.waitForExistence(timeout: 8))
XCTAssertGreaterThan(
agent.frame.width,
80,
"The provider pill must retain enough width to show its label"
)
XCTAssertEqual(agent.value as? String, "Claude")
XCTAssertGreaterThan(
model.frame.width,
100,
"The model pill must retain enough width to show its selected model"
)
// XCUI reports the visible intersection of a scroll-view descendant,
// so the model's frame can be narrower than its intrinsic pill while
// the trailing portion waits offscreen. Its full selected value must
// remain exposed, and the visible portion must not collapse to an icon.
XCTAssertEqual(model.value as? String, "Claude Opus 4.8")
model.tap()
let modelChoice = app.buttons["Claude Opus 4.8"]
XCTAssertTrue(modelChoice.waitForExistence(timeout: 3))
modelChoice.tap()
let effort = scroller.buttons["MobileTaskComposerEffortPill"]
XCTAssertTrue(
effort.waitForExistence(timeout: 3),
"Effort must share the provider and model horizontal scroller"
)
XCTAssertEqual(effort.value as? String, "Medium")
XCTAssertLessThan(model.frame.midX, effort.frame.midX)
let effortMidXBeforeScroll = effort.frame.midX
scroller.swipeLeft()
XCTAssertLessThan(
effort.frame.midX,
effortMidXBeforeScroll,
"Effort must move with the shared picker scroller"
)
effort.tap()
for choice in ["Low", "Medium", "High"] {
XCTAssertTrue(
app.buttons[choice].waitForExistence(timeout: 3),
"The selected model must expose its exact effort choice: \(choice)"
)
}
}
@MainActor
func testDefaultModelUsesProviderDefaultEfforts() {
let app = XCUIApplication()
app.launchArguments += [
"-AppleLanguages",
"(en)",
"-AppleLocale",
"en_US",
]
app.launchEnvironment["CMUX_UITEST_MOCK_DATA"] = "0"
app.launchEnvironment["CMUX_UITEST_TASK_COMPOSER_PREVIEW"] = "1"
app.launchEnvironment["CMUX_UITEST_TASK_MODEL_CATALOG_JSON"] = #"{"schemaVersion":1,"updatedAt":"2026-08-14T00:00:00Z","providers":{"claude":{"defaultModel":"claude-opus","models":[{"id":"claude-opus","label":"Claude Opus 4.8","efforts":[{"value":"low","label":"Low"},{"value":"medium","label":"Medium"},{"value":"high","label":"High"}],"defaultEffort":"medium"}]}}}"#
app.launch()
defer { app.terminate() }
let scroller = app.scrollViews["MobileTaskComposerPillScroller"]
XCTAssertTrue(scroller.waitForExistence(timeout: 8))
let effort = scroller.buttons["MobileTaskComposerEffortPill"]
XCTAssertTrue(
effort.waitForExistence(timeout: 3),
"Default model must expose efforts from the provider default model"
)
XCTAssertEqual(effort.value as? String, "Medium")
effort.tap()
for choice in ["Low", "Medium", "High"] {
XCTAssertTrue(
app.buttons[choice].waitForExistence(timeout: 3),
"The provider default model must expose its exact effort choice: \(choice)"
)
}
}
}
+69 -11
View File
@@ -4,7 +4,7 @@ import UIKit
import XCTest
final class cmuxUITests: XCTestCase {
private static let taskComposerModelCatalogJSON = #"{"schemaVersion":1,"updatedAt":"2026-08-09T00:00:00Z","providers":{"claude":{"defaultModel":"claude-opus-4-8","models":[{"id":"claude-opus-4-8","label":"Opus 4.8"}]},"codex":{"defaultModel":"gpt-5.5","models":[{"id":"gpt-5.5","label":"GPT-5.5"}]},"opencode":{"defaultModel":"anthropic/claude-opus-4-8","models":[{"id":"anthropic/claude-opus-4-8","label":"Claude Opus 4.8"}]}}}"#
private static let taskComposerModelCatalogJSON = #"{"schemaVersion":1,"updatedAt":"2026-08-09T00:00:00Z","providers":{"claude":{"defaultModel":"claude-opus-4-8","models":[{"id":"claude-opus-4-8","label":"Opus 4.8"}]},"codex":{"defaultModel":"gpt-5.5","models":[{"id":"gpt-5.5","label":"GPT-5.5","efforts":[{"value":"medium","label":"Medium"},{"value":"high","label":"High"}],"defaultEffort":"medium"},{"id":"gpt-5.5-mini","label":"GPT-5.5 Mini","efforts":[{"value":"low","label":"Low"}],"defaultEffort":"low"}]},"opencode":{"defaultModel":"anthropic/claude-opus-4-8","models":[{"id":"anthropic/claude-opus-4-8","label":"Claude Opus 4.8"}]}}}"#
override func setUpWithError() throws {
continueAfterFailure = false
@@ -4165,7 +4165,7 @@ final class cmuxUITests: XCTestCase {
XCTAssertTrue(submittedCommand.waitForExistence(timeout: 4))
XCTAssertEqual(
submittedCommand.label,
"codex -m 'gpt-5.5' -- \"$CMUX_TASK_PROMPT\""
"codex -c model_reasoning_effort='medium' -m 'gpt-5.5' -- \"$CMUX_TASK_PROMPT\""
)
app.terminate()
@@ -4208,6 +4208,62 @@ final class cmuxUITests: XCTestCase {
)
}
/// Effort choices belong to the selected model, stay visible beside the
/// model control, and must be replaced when the model changes.
@MainActor
func testTaskComposerEffortPickerUsesSelectedModelCatalog() throws {
let app = launchApp(mockData: false, environment: [
"CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1",
])
defer { app.terminate() }
XCTAssertTrue(taskComposerPrompt(in: app).waitForExistence(timeout: 8))
selectTaskComposerAgent(named: "Codex", in: app)
let model = app.buttons["MobileTaskComposerModelPill"]
XCTAssertTrue(model.waitForExistence(timeout: 4))
tap(model, in: app)
tapMenuItem(app.buttons["GPT-5.5"], in: app)
let effort = app.buttons["MobileTaskComposerEffortPill"]
XCTAssertTrue(effort.waitForExistence(timeout: 3))
XCTAssertEqual(effort.value as? String, "Medium")
XCTAssertLessThan(model.frame.midX, effort.frame.midX)
XCTAssertLessThan(
effort.frame.midX,
app.buttons["MobileTaskComposerSubmitButton"].frame.midX
)
tap(effort, in: app)
XCTAssertTrue(app.buttons["High"].waitForExistence(timeout: 2))
tapMenuItem(app.buttons["High"], in: app)
XCTAssertEqual(effort.value as? String, "High")
tap(model, in: app)
tapMenuItem(app.buttons["GPT-5.5 Mini"], in: app)
XCTAssertEqual(effort.value as? String, "Low")
let proof = XCTAttachment(screenshot: app.screenshot())
proof.name = "Native task composer model and effort pickers"
proof.lifetime = .keepAlways
add(proof)
tap(effort, in: app)
XCTAssertTrue(app.buttons["Low"].waitForExistence(timeout: 2))
XCTAssertFalse(app.buttons["High"].exists)
tapMenuItem(app.buttons["Low"], in: app)
try typeText("Use the exact model effort", into: taskComposerPrompt(in: app), in: app)
let submit = app.buttons["MobileTaskComposerSubmitButton"]
expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: submit)
waitForExpectations(timeout: 3)
tap(submit, in: app)
let submittedCommand = app.staticTexts["MobileTaskComposerSubmittedInitialCommand"]
XCTAssertTrue(submittedCommand.waitForExistence(timeout: 4))
XCTAssertEqual(
submittedCommand.label,
"codex -c model_reasoning_effort='low' -m 'gpt-5.5-mini' -- \"$CMUX_TASK_PROMPT\""
)
}
/// A UIKit menu retains the model snapshot it presented. If installed-agent
/// discovery replaces the live backend catalog before the user taps, the
/// visible snapshot choice must still become the submitted model.
@@ -4494,7 +4550,7 @@ final class cmuxUITests: XCTestCase {
}
/// The Composer pill scroller must clip between its neighboring controls;
/// it must not underlap them to render a blur or fade at either edge.
/// its pills retain readable intrinsic widths and scroll behind hard edges.
@MainActor
func testTaskComposerComposerPillScrollerUsesHardEdges() throws {
let app = launchApp(mockData: false, environment: [
@@ -4535,15 +4591,17 @@ final class cmuxUITests: XCTestCase {
XCTAssertGreaterThanOrEqual(scroller.frame.minX, options.frame.maxX)
XCTAssertLessThanOrEqual(scroller.frame.maxX, submit.frame.minX)
XCTAssertGreaterThanOrEqual(modelPill.frame.minX, scroller.frame.minX)
XCTAssertLessThanOrEqual(
modelPill.frame.maxX,
scroller.frame.maxX,
"A long selected model must compress inside the pill viewport"
XCTAssertGreaterThan(
modelPill.frame.width,
120,
"A long selected model must keep enough width to show its label"
)
XCTAssertLessThanOrEqual(
modelPill.frame.maxX,
submit.frame.minX,
"The selected model must never extend beneath Submit"
let modelMidXBeforeScroll = modelPill.frame.midX
scroller.swipeLeft()
XCTAssertLessThan(
modelPill.frame.midX,
modelMidXBeforeScroll,
"Overflowing picker pills must move together inside the scroller"
)
let attachment = XCTAttachment(screenshot: app.screenshot())
-12
View File
@@ -43,14 +43,6 @@ export interface AgentModelCatalog {
};
}
const CODEX_REASONING_EFFORTS: AgentModelChoice[] = [
{ value: "none", label: "none" },
{ value: "low", label: "low" },
{ value: "medium", label: "medium" },
{ value: "high", label: "high" },
{ value: "xhigh", label: "xhigh" },
];
export const agentModelCatalog = {
schemaVersion: 1,
updatedAt: "2026-08-09T00:00:00.000Z",
@@ -120,8 +112,6 @@ export const agentModelCatalog = {
description: "Frontier model for complex coding, computer use, knowledge work, and research workflows in Codex.",
contextWindow: 1050000,
supportsOneMillion: true,
efforts: CODEX_REASONING_EFFORTS,
defaultEffort: "medium",
isDefault: true,
},
{
@@ -130,8 +120,6 @@ export const agentModelCatalog = {
description: "Higher-capability GPT-5.5 model for difficult professional work.",
contextWindow: 1050000,
supportsOneMillion: true,
efforts: CODEX_REASONING_EFFORTS,
defaultEffort: "medium",
},
],
},
+1 -7
View File
@@ -51,13 +51,7 @@ describe("agent models route", () => {
"gpt-5.5",
"gpt-5.5-pro",
]);
expect(agentModelCatalog.providers.codex.models[0].efforts?.map((effort) => effort.value)).toEqual([
"none",
"low",
"medium",
"high",
"xhigh",
]);
expect(agentModelCatalog.providers.codex.models.every((model) => !("efforts" in model))).toBe(true);
expect(agentModelCatalog.providers.opencode).toEqual({
defaultModel: "anthropic/claude-sonnet-5",