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:
@@ -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
|
||||
|
||||
+134
-35
@@ -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(
|
||||
|
||||
+60
-3
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-2
@@ -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",
|
||||
|
||||
+69
-2
@@ -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(
|
||||
|
||||
+121
-11
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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
|
||||
}
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+5
-1
@@ -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.
|
||||
|
||||
+23
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
+73
@@ -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",
|
||||
|
||||
+3
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -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
|
||||
+120
-22
@@ -6,6 +6,7 @@ import UIKit
|
||||
|
||||
/// A full-screen prompt canvas with compact task controls above the keyboard.
|
||||
struct TaskComposerLayout: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
|
||||
@Binding var prompt: String
|
||||
let genericPromptPlaceholder: String
|
||||
let workspaceName: String
|
||||
@@ -16,6 +17,11 @@ struct TaskComposerLayout: View {
|
||||
let selectedTemplateID: MobileTaskTemplate.ID?
|
||||
let models: [MobileTaskAgentModel]
|
||||
let selectedModelID: String?
|
||||
let efforts: [MobileTaskAgentEffort]
|
||||
let selectedEffortID: String?
|
||||
let modelErrorText: String?
|
||||
let effortErrorText: String?
|
||||
let agentErrorText: String?
|
||||
let isModelLoading: Bool
|
||||
let isSubmitting: Bool
|
||||
let isSubmitEnabled: Bool
|
||||
@@ -31,6 +37,7 @@ struct TaskComposerLayout: View {
|
||||
let endEditing: () -> Void
|
||||
let selectTemplate: (MobileTaskTemplate.ID) -> Void
|
||||
let selectModel: (MobileTaskAgentModel?) -> Void
|
||||
let selectEffort: (MobileTaskAgentEffort?) -> Void
|
||||
let editTemplates: () -> Void
|
||||
let cancel: () -> Void
|
||||
let submit: () -> Void
|
||||
@@ -138,27 +145,32 @@ struct TaskComposerLayout: View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 8) {
|
||||
agentPill
|
||||
// Keep the provider readable before compressing
|
||||
// the model label on compact rows.
|
||||
.layoutPriority(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
if !models.isEmpty {
|
||||
modelPill
|
||||
} else if isModelLoading {
|
||||
if isModelLoading {
|
||||
modelLoadingPill
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.transition(modelLoadingTransition)
|
||||
} else if !models.isEmpty || !efforts.isEmpty || modelErrorText != nil {
|
||||
if models.isEmpty, modelErrorText != nil {
|
||||
modelErrorPill
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
} else {
|
||||
modelPill
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
|
||||
if !efforts.isEmpty || effortErrorText != nil {
|
||||
effortPill
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Give the pills the viewport's finite width so their
|
||||
// one-line labels compress inside their own capsules.
|
||||
// Without this, ScrollView proposes infinite width and a
|
||||
// long selected model extends beneath the fixed submit
|
||||
// control before clipping at the viewport edge.
|
||||
.containerRelativeFrame(.horizontal, alignment: .leading)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
// The pills are the row's only compressible region. A zero
|
||||
// minimum lets the fixed 44pt edge controls claim their space
|
||||
// before this viewport receives the remaining width.
|
||||
// The shared picker strip owns overflow. Pills keep their
|
||||
// readable intrinsic widths while the fixed edge controls
|
||||
// claim their space outside this clipped viewport.
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.layoutPriority(0)
|
||||
.clipped()
|
||||
@@ -234,7 +246,7 @@ struct TaskComposerLayout: View {
|
||||
TaskTemplateIcon(value: selectedTemplate.icon, size: 16)
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
Text(agentPillTitle(for: selectedTemplate))
|
||||
Text(agentErrorText ?? agentPillTitle(for: selectedTemplate))
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle.badge.exclamationmark")
|
||||
@@ -253,7 +265,7 @@ struct TaskComposerLayout: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(agentErrorText == nil ? Color.primary : Color.red)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: 38)
|
||||
.background(Color.primary.opacity(0.07), in: Capsule())
|
||||
@@ -263,13 +275,13 @@ struct TaskComposerLayout: View {
|
||||
.tint(Color.primary)
|
||||
.disabled(isDisabled)
|
||||
.accessibilityLabel(L10n.string("mobile.taskComposer.agent", defaultValue: "Agent"))
|
||||
.accessibilityValue(selectedTemplate?.name ?? "")
|
||||
.accessibilityValue(agentErrorText ?? selectedTemplate?.name ?? "")
|
||||
.accessibilityHint(TaskComposerSheet.templateAccessibilityHint)
|
||||
.accessibilityIdentifier("MobileTaskComposerAgentPill")
|
||||
// Recreate the whole Menu when the title changes: the UIKit menu
|
||||
// button otherwise animates its frame to the new width on iOS 26 and
|
||||
// clips the label against the stale bounds until it settles.
|
||||
.id(selectedTemplate.map(agentPillTitle(for:)))
|
||||
.id((selectedTemplate.map(agentPillTitle(for:)) ?? "") + (agentErrorText ?? ""))
|
||||
}
|
||||
|
||||
private var modelPill: some View {
|
||||
@@ -278,7 +290,7 @@ struct TaskComposerLayout: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(selectedModelName)
|
||||
Text(modelErrorText ?? selectedModelName)
|
||||
.lineLimit(1)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
@@ -287,7 +299,7 @@ struct TaskComposerLayout: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(modelErrorText == nil ? Color.primary : Color.red)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: 38)
|
||||
.background(Color.primary.opacity(0.07), in: Capsule())
|
||||
@@ -306,7 +318,33 @@ struct TaskComposerLayout: View {
|
||||
}
|
||||
// See agentPill: identity-swap the pill so the new title cannot be
|
||||
// clipped by the old frame mid-animation.
|
||||
.id(selectedModelName)
|
||||
.id((modelErrorText ?? "") + selectedModelName)
|
||||
}
|
||||
|
||||
private var modelErrorPill: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.caption.weight(.semibold))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(modelErrorText ?? L10n.string(
|
||||
"mobile.taskComposer.model.error.queryFailed",
|
||||
defaultValue: "Couldn’t load models"
|
||||
))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.red)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: 38)
|
||||
.background(Color.red.opacity(0.12), in: Capsule())
|
||||
.frame(minHeight: 44)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(modelErrorText ?? L10n.string(
|
||||
"mobile.taskComposer.model.error.queryFailed",
|
||||
defaultValue: "Couldn’t load models"
|
||||
))
|
||||
.accessibilityIdentifier("MobileTaskComposerModelErrorPill")
|
||||
}
|
||||
|
||||
private var modelLoadingPill: some View {
|
||||
@@ -338,6 +376,55 @@ struct TaskComposerLayout: View {
|
||||
.accessibilityIdentifier("MobileTaskComposerModelLoadingPill")
|
||||
}
|
||||
|
||||
private var modelLoadingTransition: AnyTransition {
|
||||
guard !accessibilityReduceMotion else { return .identity }
|
||||
return .asymmetric(
|
||||
insertion: .opacity
|
||||
.combined(with: .scale(scale: 0.96)),
|
||||
removal: .opacity
|
||||
.combined(with: .scale(scale: 0.98))
|
||||
)
|
||||
}
|
||||
|
||||
private var effortPill: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "gauge.with.dots.needle.33percent")
|
||||
.font(.caption.weight(.semibold))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(effortErrorText ?? selectedEffortName)
|
||||
.lineLimit(1)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(
|
||||
effortErrorText != nil
|
||||
? Color.red
|
||||
: (efforts.isEmpty ? Color.secondary : Color.primary)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: 38)
|
||||
.background(Color.primary.opacity(0.07), in: Capsule())
|
||||
.frame(minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityHidden(true)
|
||||
.overlay {
|
||||
TaskComposerEffortMenuContent(
|
||||
efforts: efforts,
|
||||
selectedEffortID: selectedEffortID,
|
||||
selectedEffortName: selectedEffortName,
|
||||
isEnabled: !isDisabled && !efforts.isEmpty,
|
||||
selectEffort: selectEffort
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.id((effortErrorText ?? "") + selectedEffortName)
|
||||
}
|
||||
|
||||
private var submitButton: some View {
|
||||
Button(action: submit) {
|
||||
Group {
|
||||
@@ -381,6 +468,17 @@ struct TaskComposerLayout: View {
|
||||
models.displayName(forSelected: selectedModelID)
|
||||
}
|
||||
|
||||
private var selectedEffortName: String {
|
||||
guard let selectedEffortID,
|
||||
let effort = efforts.first(where: { $0.id == selectedEffortID }) else {
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.effort",
|
||||
defaultValue: "Effort"
|
||||
)
|
||||
}
|
||||
return effort.displayName
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
let trimmedWorkspaceName = workspaceName.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+95
-2
@@ -1,8 +1,62 @@
|
||||
#if os(iOS)
|
||||
import CMUXMobileCore
|
||||
import CmuxMobileShellModel
|
||||
import CmuxMobileSupport
|
||||
|
||||
extension TaskComposerSheet {
|
||||
var modelPickerErrorText: String? {
|
||||
guard let displayedModelError else { return nil }
|
||||
switch displayedModelError {
|
||||
case .providerUnavailable:
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.model.error.providerUnavailable",
|
||||
defaultValue: "Agent models unavailable"
|
||||
)
|
||||
case .queryFailed:
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.model.error.queryFailed",
|
||||
defaultValue: "Couldn’t load models"
|
||||
)
|
||||
case .hostUnavailable:
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.model.error.hostUnavailable",
|
||||
defaultValue: "Mac unavailable"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var agentPickerErrorText: String? {
|
||||
guard displayedModelError == .providerUnavailable,
|
||||
let provider = modelRefreshID.provider else { return nil }
|
||||
switch provider {
|
||||
case .claude:
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.agent.error.claudeUnavailable",
|
||||
defaultValue: "Claude unavailable"
|
||||
)
|
||||
case .codex:
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.agent.error.codexUnavailable",
|
||||
defaultValue: "Codex unavailable"
|
||||
)
|
||||
case .openCode:
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.agent.error.openCodeUnavailable",
|
||||
defaultValue: "OpenCode unavailable"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var effortPickerErrorText: String? {
|
||||
guard displayedModelError != nil,
|
||||
!availableModels.isEmpty,
|
||||
availableEfforts.isEmpty else { return nil }
|
||||
return L10n.string(
|
||||
"mobile.taskComposer.effort.error.queryFailed",
|
||||
defaultValue: "Couldn’t load efforts"
|
||||
)
|
||||
}
|
||||
|
||||
var modelAvailability: MobileTaskModelAvailability {
|
||||
guard let selectedTemplate,
|
||||
MobileTaskAgentProvider(
|
||||
@@ -10,12 +64,14 @@ extension TaskComposerSheet {
|
||||
) != nil else {
|
||||
return MobileTaskModelAvailability(
|
||||
template: selectedTemplate,
|
||||
discoveredModels: nil
|
||||
discoveredModels: nil,
|
||||
defaultModel: nil
|
||||
)
|
||||
}
|
||||
return MobileTaskModelAvailability(
|
||||
template: selectedTemplate,
|
||||
discoveredModels: displayedModels
|
||||
discoveredModels: displayedModels,
|
||||
defaultModel: displayedDefaultModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +101,19 @@ extension TaskComposerSheet {
|
||||
return availableModels.first { $0.id == selectedModelID }
|
||||
}
|
||||
|
||||
var availableEfforts: [MobileTaskAgentEffort] {
|
||||
selectedModel?.efforts ?? modelAvailability.defaultModel?.efforts ?? []
|
||||
}
|
||||
|
||||
var effortDefaultModel: MobileTaskAgentModel? {
|
||||
selectedModel ?? modelAvailability.defaultModel
|
||||
}
|
||||
|
||||
var selectedEffort: MobileTaskAgentEffort? {
|
||||
guard let selectedEffortID else { return nil }
|
||||
return availableEfforts.first { $0.id == selectedEffortID }
|
||||
}
|
||||
|
||||
func validatedModelID(
|
||||
_ id: String?,
|
||||
for template: MobileTaskTemplate,
|
||||
@@ -79,11 +148,35 @@ extension TaskComposerSheet {
|
||||
updateSubmissionRequest(reconcileRecovery: true) {
|
||||
selectedModelID = selectedID
|
||||
explicitlySelectedModel = model
|
||||
selectedEffortID = (model ?? modelAvailability.defaultModel)?.defaultEffortID
|
||||
}
|
||||
store.recordAppEvent(
|
||||
.taskModelSelected,
|
||||
correlationID: selectedID
|
||||
)
|
||||
}
|
||||
|
||||
func selectEffort(_ effort: MobileTaskAgentEffort?) {
|
||||
guard !submissionPhase.disablesRequestEditing else { return }
|
||||
let selectedID = effort?.id
|
||||
guard availableEfforts.contains(where: { $0.id == selectedID }),
|
||||
selectedEffortID != selectedID else { return }
|
||||
updateSubmissionRequest(reconcileRecovery: true) {
|
||||
selectedEffortID = selectedID
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileSelectedEffort() {
|
||||
let reconciledID: String?
|
||||
if availableEfforts.contains(where: { $0.id == selectedEffortID }) {
|
||||
reconciledID = selectedEffortID
|
||||
} else {
|
||||
reconciledID = effortDefaultModel?.defaultEffortID
|
||||
}
|
||||
guard selectedEffortID != reconciledID else { return }
|
||||
updateSubmissionRequest(reconcileRecovery: true) {
|
||||
selectedEffortID = reconciledID
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
+76
-11
@@ -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()
|
||||
}
|
||||
|
||||
+27
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+139
-28
@@ -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] {
|
||||
|
||||
+69
-10
@@ -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
|
||||
|
||||
+75
-11
@@ -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(
|
||||
|
||||
+70
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
] },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
@@ -25,6 +25,8 @@ export interface OptionChoice {
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
efforts?: OptionChoice[];
|
||||
defaultEffort?: string;
|
||||
}
|
||||
|
||||
export interface SessionOption {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "Couldn’t 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": "Couldn’t 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": {
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user