Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions cmux-tui/crates/acpmux/src/claude_stdio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,22 @@ fn effort_value(effort: &str) -> &str {
}
}

/// The model id Claude Code accepts for `value`. A versioned family name
/// without the `claude-` prefix (`opus-5-5`, `sonnet-5-5[1m]`) is not a
/// Claude Code alias: Claude Code sends it to the API as typed and every turn
/// fails with "There's an issue with the selected model (opus-5-5)". It takes
/// the prefix, as the live list's `launch_id` does. Bare aliases (`opus`,
/// `opusplan`), full ids and other providers' names pass unchanged.
pub fn model_id(value: &str) -> String {
let value = value.trim();
let versioned = ["opus-", "sonnet-", "haiku-"].iter().any(|family| {
value
.strip_prefix(family)
.is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
});
if versioned { format!("claude-{value}") } else { value.to_owned() }
}

/// The Reasoning select's value for a session effort: Ultracode runs at xhigh.
fn shown_level(effort: &str) -> &str {
match effort_value(effort) {
Expand Down Expand Up @@ -375,7 +391,7 @@ pub fn spawn_plan(
// A later --model wins over one the profile pins in its argv.
if let Some(m) = model.filter(|m| !m.is_empty() && *m != "default") {
args.push("--model".into());
args.push(m.into());
args.push(model_id(m));
}
// Lets the chat switch to bypassPermissions ("Bypass permissions") later; a
// process that cannot take it is never given bypassPermissions at spawn.
Expand Down Expand Up @@ -593,7 +609,7 @@ impl Translator {
session_id: Mutex::new(None),
acp_session_id,
mode: Mutex::new(mode.to_owned()),
model: Mutex::new(model.to_owned()),
model: Mutex::new(model_id(model)),
effort: Mutex::new(effort_value(effort).to_owned()),
fast: Mutex::new(false),
in_turn: AtomicBool::new(false),
Expand Down
5 changes: 3 additions & 2 deletions cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ impl Translator {
let val = p.get("value").and_then(Value::as_str).unwrap_or("").to_owned();
match cid {
"model" => {
let val = super::model_id(&val);
self.pending.lock().await.insert(
id.to_string(),
Pending::Control(Setting::Model, val.clone()),
Expand Down Expand Up @@ -244,8 +245,8 @@ impl Translator {
let val = p
.get("modelId")
.and_then(Value::as_str)
.unwrap_or("default")
.to_owned();
.unwrap_or("default");
let val = super::model_id(val);
self.pending
.lock()
.await
Expand Down
36 changes: 36 additions & 0 deletions cmux-tui/crates/acpmux/src/claude_stdio/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,39 @@ async fn modes_are_claude_codes_own_labels_descriptions_and_order() {
mode["options"].as_array().unwrap().iter().filter_map(|o| o["value"].as_str()).collect();
assert_eq!(values, ["auto", "default", "acceptEdits", "plan", "bypassPermissions"]);
}

/// A chat started with a versioned family name (`opus-5-5`, as a picker or
/// an agent's `--model` may pass it) failed every turn: Claude Code sent it to
/// the API unchanged ("There's an issue with the selected model (opus-5-5)").
/// The translator hands Claude Code the full id at spawn and on every switch.
#[tokio::test]
async fn versioned_family_names_reach_claude_code_as_full_ids() {
assert_eq!(model_id("opus-5-5"), "claude-opus-5-5");
assert_eq!(model_id("sonnet-5-5[1m]"), "claude-sonnet-5-5[1m]");
for unchanged in ["opus", "opusplan", "opus[1m]", "claude-opus-5-5", "default", "gpt-6.1-sol"] {
assert_eq!(model_id(unchanged), unchanged);
}

let profile: crate::config::HarnessProfile =
serde_json::from_value(json!({"kind": "claude-stdio", "argv": ["claude"]})).unwrap();
let plan = spawn_plan(&profile, None, false, None, None, "default", Some("opus-5-5"));
let at = plan.args.iter().position(|a| a == "--model").expect("--model");
assert_eq!(plan.args[at + 1], "claude-opus-5-5");

let t = Translator::new("acp-1".into(), "default", "opus-5-5", "default");
assert_eq!(*t.model.lock().await, "claude-opus-5-5");
let req = Message::request(
3,
method::SESSION_SET_MODEL,
json!({"sessionId": "acp-1", "modelId": "haiku-5-5"}),
);
let Outbound::Lines(lines) = t.outbound(&req).await else { panic!() };
assert_eq!(lines[0]["request"]["model"], "claude-haiku-5-5");
let req = Message::request(
4,
method::SESSION_SET_CONFIG_OPTION,
json!({"sessionId": "acp-1", "configId": "model", "value": "sonnet-5-5"}),
);
let Outbound::Lines(lines) = t.outbound(&req).await else { panic!() };
assert_eq!(lines[0]["request"]["model"], "claude-sonnet-5-5");
}
38 changes: 3 additions & 35 deletions webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,7 @@ const curated: ModelPickerProps = {
.map((harness) => ({
id: harness.id,
name: harness.name,
models: harness.models.map((model) => ({
id: model.id,
name: model.name,
shortName: model.shortName,
})),
models: harness.models.map((model) => ({ id: model.id, name: model.name, shortName: model.shortName })),
...(topModelsFor(harness.id) ? { top: topModelsFor(harness.id) } : {}),
})),
};
Expand Down Expand Up @@ -185,11 +181,7 @@ const manyHarnesses: ModelPickerProps["catalog"] = [
["deepagents", "DeepAgents"],
["poolside", "Poolside"],
["qoder", "Qoder CLI"],
].map(([id, name]) => ({
id: id!,
name: name!,
models: [{ id: `${id}-default`, name: "Default" }],
})),
].map(([id, name]) => ({ id: id!, name: name!, models: [{ id: `${id}-default`, name: "Default" }] })),
];

/// The open picker stays within the available anchor room on a tall stage, and its content
Expand Down Expand Up @@ -302,28 +294,12 @@ const startedFork: ModelPickerProps = {
...quick,
catalog: [
...curated.catalog,
{
...curated.catalog.find((entry) => entry.id === "claude")!,
id: "claude-work",
name: "Claude (work)",
},
{ ...curated.catalog.find((entry) => entry.id === "claude")!, id: "claude-work", name: "Claude (work)" },
],
harnessFixed: true,
fork: { to: () => undefined, ready: true },
};

const canonicalVariants: ModelPickerProps = {
...startedFork,
catalog: [
...startedFork.catalog,
{
...curated.catalog.find((entry) => entry.id === "codex")!,
id: "codex-work",
name: "Codex (work)",
},
],
};

/// A short New Tab/chat pane with 48 installed accounts: the menu must cap to its window and
/// let the Account list scroll to its last row without an empty spacer.
const manyProfiles: ModelPickerProps = {
Expand Down Expand Up @@ -614,14 +590,6 @@ export default componentEntry<GalleryProps>({
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-quick-fork") !== null);
},
},
"canonical-harnesses-chat": {
props: canonicalVariants,
play: openQuick,
},
"canonical-harnesses-new-chat": {
props: { ...canonicalVariants, harnessFixed: false, fork: undefined, onHarness: () => undefined },
play: openQuick,
},
// Its Account row contains signed-in routes, separate from named launch harnesses.
"started-chat-accounts": {
props: startedFork,
Expand Down
20 changes: 11 additions & 9 deletions webviews/src/agent-session/acpmux/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import {
PickerOptionList,
} from "../../ui/PickerPrimitives";
import { ScrollArea } from "../../ui/ScrollArea";
import { canonicalHarnesses } from "./canonicalHarnesses";
import { quantizePickerRail } from "./pickerRailGeometry";

type HarnessChoice = {
Expand Down Expand Up @@ -299,15 +298,20 @@ function quickRow(family: string, model: ModelChoice): QuickModel {
/// The rail's Starred tab (never a harness id).
const STARRED = "\u0000starred";

/// Build launch entries before folding Claude/Codex variants into their canonical harness tab.
/// Their model catalogs refresh after selection; do not borrow another account's models.
/// Global launch configurations are distinct harness entries, never a Profile submenu.
/// One whose own catalog entry lists models shows them, as any harness tab does; only one with
/// no models of its own launches from its tab. Do not borrow another account's models.
function railByHarness(
list: QuickFamily[],
running: string | undefined,
profiles: Readonly<Record<string, readonly PickerProfile[]>> = {},
signedOut: (id: string) => boolean = () => false,
): QuickFamily[] {
const seen = new Set<string>();
const byId = new Map(list.map((family) => [family.id, family]));
// Only a row that can be picked starts the entry; one whose probe failed keeps its launch tab.
const hasModels = (family: QuickFamily | undefined) =>
!!family && [...family.models, ...family.more].some((model) => !model.disabled);
return list.flatMap((family) => {
if (seen.has(family.id)) return [];
seen.add(family.id);
Expand All @@ -323,7 +327,7 @@ function railByHarness(
);
const currentName =
names.find((profile) => profile.id === family.id)?.name ?? launchName(family.name, family.name, family.id);
const alternate = launchSuffix(family.id) && family.id !== running;
const alternate = launchSuffix(family.id) && family.id !== running && !hasModels(family);
const own = { ...family, name: currentName, ...(alternate ? { launch: true } : {}) };
return [
own,
Expand All @@ -338,6 +342,8 @@ function railByHarness(
)
return [];
seen.add(profile.id);
const entry = byId.get(profile.id);
if (hasModels(entry)) return [{ ...entry!, name: profile.name, mark: entry!.mark ?? family.mark }];
return [
{
id: profile.id,
Expand Down Expand Up @@ -929,10 +935,7 @@ export function ModelPicker(props: ModelPickerProps) {
: {}),
});
}
const rail = canonicalHarnesses(
railByHarness(list, harness ?? current?.id, props.profiles, (id) => signedOut?.[id] !== undefined),
current?.id,
);
const rail = railByHarness(list, harness ?? current?.id, props.profiles, (id) => signedOut?.[id] !== undefined);
if (!harnessFixed) return rail;
// A started chat (Leo 2026-10-22: "can expose as fork"): another harness's tab is Fork to it,
// Starred keeps the chat's own models, and another launch profile of any harness forks there.
Expand All @@ -943,7 +946,6 @@ export function ModelPicker(props: ModelPickerProps) {
return models.length ? [{ ...family, models }] : [];
}
if (family.id === current?.id || family.note) return [own];
if (family.launch) return [{ ...own, models: [], more: [], fork: true }];
return [{ ...own, models: [], more: [], fork: true }];
});
}, [
Expand Down
22 changes: 0 additions & 22 deletions webviews/src/agent-session/acpmux/QuickModelMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,6 @@ export type QuickModel = {
/// their place (a signed-out harness). `mark` is the AgentMark brand; without one the tab draws
/// the name's initial.
export type QuickFamily = {
/// Alternate launch configurations inside the canonical harness tab, never duplicate rail tabs.
variants?: QuickFamily[];
id: string;
name: string;
mark?: string;
Expand Down Expand Up @@ -785,26 +783,6 @@ export function QuickModelMenu({
)}
</>
)}
{!query && shown?.variants && shown.variants.length > 0 && (
<MenuGroup label={shown.name}>
{shown.variants.map((variant) => (
<MenuItem
key={variant.id}
className="acpmux-quick-item acpmux-quick-variant"
disabled={families?.forkReady === false}
closeOnClick
onSelect={() => {
if (variant.logIn) variant.logIn();
else families?.onLaunch?.(variant.id);
}}
>
<span className="acpmux-menu-label">
{families?.onFork ? t("picker.forkTo", { harness: variant.name }) : variant.name}
</span>
</MenuItem>
))}
</MenuGroup>
)}
{!query &&
onChatTab &&
effort &&
Expand Down
61 changes: 0 additions & 61 deletions webviews/src/agent-session/acpmux/canonicalHarnesses.test.ts

This file was deleted.

38 changes: 0 additions & 38 deletions webviews/src/agent-session/acpmux/canonicalHarnesses.ts

This file was deleted.

Loading
Loading