Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
caca5c9
test: cover pending model catalogs in shared picker
azooz2003-bit Aug 13, 2026
b392353
Show model catalog loading in shared picker
azooz2003-bit Aug 13, 2026
265919c
Merge remote-tracking branch 'origin/main' into feat-model-loading-in…
azooz2003-bit Aug 14, 2026
f6c0fef
test: expose buried model loading state
azooz2003-bit Aug 14, 2026
9ac7d34
Make pending model picker unmistakable
azooz2003-bit Aug 14, 2026
b680bd1
test: require model-specific effort picker
azooz2003-bit Aug 14, 2026
0c2263a
Use model-specific effort metadata
azooz2003-bit Aug 14, 2026
3d25c3f
test: require native model effort picker
azooz2003-bit Aug 14, 2026
f2b42c1
test: isolate native effort picker regression
azooz2003-bit Aug 15, 2026
89cfb1b
test: isolate iOS UI test plan
azooz2003-bit Aug 15, 2026
5474dd7
feat: add native model effort picker
azooz2003-bit Aug 15, 2026
72383e3
test: verify native effort choices
azooz2003-bit Aug 15, 2026
11d27fd
test: cover all native effort choices
azooz2003-bit Aug 15, 2026
eae4a5a
test: require shared readable picker scroller
azooz2003-bit Aug 15, 2026
f96c7da
fix: keep task pickers readable in one scroller
azooz2003-bit Aug 15, 2026
c1440dd
test: measure visible picker labels accurately
azooz2003-bit Aug 15, 2026
543670b
test: cover default model effort picker
azooz2003-bit Aug 17, 2026
9925d6e
fix: source efforts for the default model
azooz2003-bit Aug 17, 2026
d50b5fe
fix: keep default efforts through draft restore
azooz2003-bit Aug 17, 2026
832a692
test: cover unpinned default model efforts
azooz2003-bit Aug 17, 2026
0c05c0c
test: define semantic OpenCode effort ordering
azooz2003-bit Aug 18, 2026
9acfd87
fix: order OpenCode effort variants semantically
azooz2003-bit Aug 18, 2026
efab5db
test: cover task model discovery failures
azooz2003-bit Aug 18, 2026
955dad1
fix: surface task model discovery errors
azooz2003-bit Aug 18, 2026
7c1f439
fix: keep model discovery errors readable
azooz2003-bit Aug 18, 2026
e0dfda2
fix: resolve provider loading review findings
azooz2003-bit Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 58 additions & 23 deletions agent-chat/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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 },
],
},
Expand Down Expand Up @@ -145,7 +146,7 @@ export const claudeAdapter: Adapter = {
modelMeta: choices.meta,
permissionMode: "acceptEdits",
thinking: "0",
effort: "medium",
effort: "",
fastMode: false,
context: "200k",
};
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 };
}
Expand Down Expand Up @@ -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);
Expand All @@ -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)) {
Expand All @@ -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;
Expand Down
28 changes: 18 additions & 10 deletions agent-chat/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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" }] },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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 };
}

Expand Down
Loading