From 3166c99a124c803773e6814e66716eb86ad2e125 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Tue, 10 Nov 2026 20:00:42 -0500 Subject: [PATCH] Show Claude models by the name Claude Code lists: Fable 5.2, not fable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card, the quick prompt, the model submenu, the Agents tab and a preset all printed the id nebula stores and passes to `--model` — `fable`, which says nothing about which Fable. They now print the name Claude Code's own `/model` picker lists: `Fable 5.2`, `Opus 5.5 (1M context)`. The names are not hardcoded. Claude Code caches the catalog its picker is drawn from under `/cache/model-catalog/`; the TUI reads the newest file at start and again every ten minutes, off the event loop, and maps alias -> id -> name through it, following the provider Claude Code is pointed at. The stored id is untouched, so `--model`, resume, config.json and type-ahead are as before. An id the catalog does not name, and every id on a machine with no catalog, prints as it is. Other harnesses' ids are never renamed. Co-Authored-By: Claude Fable 5.2 --- crates/nebula-tui/src/agent_presets.rs | 6 +- crates/nebula-tui/src/claude_catalogue.rs | 374 +++++++++++++++++- crates/nebula-tui/src/config.rs | 24 +- crates/nebula-tui/src/event_loop/actions.rs | 10 +- crates/nebula-tui/src/preset_overlays.rs | 2 +- crates/nebula-tui/src/quick_prompt.rs | 6 +- crates/nebula-tui/src/ui/launcher_view.rs | 105 ++++- .../nebula-tui/src/ui/launcher_view/nested.rs | 12 +- crates/nebula-tui/src/ui/overlay.rs | 2 +- docs/configuration.md | 2 +- docs/how-it-works.md | 14 +- 11 files changed, 533 insertions(+), 24 deletions(-) diff --git a/crates/nebula-tui/src/agent_presets.rs b/crates/nebula-tui/src/agent_presets.rs index 61cb5e66..fc771102 100644 --- a/crates/nebula-tui/src/agent_presets.rs +++ b/crates/nebula-tui/src/agent_presets.rs @@ -55,7 +55,11 @@ impl AgentPreset { .as_deref() .unwrap_or_else(|| self.kind.as_str()); let mut parts = vec![harness.to_string()]; - parts.extend(self.model.iter().cloned()); + parts.extend( + self.model + .iter() + .map(|model| crate::config::model_label(self.kind, model)), + ); parts.extend(self.effort.iter().cloned()); parts.join(" · ") } diff --git a/crates/nebula-tui/src/claude_catalogue.rs b/crates/nebula-tui/src/claude_catalogue.rs index fb264896..f18be088 100644 --- a/crates/nebula-tui/src/claude_catalogue.rs +++ b/crates/nebula-tui/src/claude_catalogue.rs @@ -28,7 +28,32 @@ //! any policy. Lists are handed out as `&'static` slices, the shape every //! MODEL / EFFORT surface already takes, by leaking a new one only when the //! resolved list actually changes. - +//! +//! # MODEL NAMES +//! +//! The ids above are what `--model` takes; they are not what a person reads. +//! `fable` says nothing about which Fable, and Claude Code's own `/model` +//! picker lists `Fable 5.2`. Claude Code keeps the catalog that picker is +//! drawn from on disk, refreshed every few minutes while it runs: +//! +//! ```text +//! /cache/model-catalog/-cc.json +//! {"fetchedAt":…,"catalog":{"surface":"cc","config":{ +//! "models":[{"id":"claude-fable-5-2","name":"Fable 5.2",…},…], +//! "provider_alias_targets":{"fable":{"default":"claude-fable-5-2", +//! "per_provider":{"gateway":"claude-fable-5"}},…}}}} +//! ``` +//! +//! (verified on 2.1.322; the signed `published-*.json` fallback carries the +//! same two tables under `document.surfaces.cc.model_selector_config`). +//! [`label`] maps a stored id through the newest of those files — alias to +//! id to name — so every surface that prints a Claude model prints the name +//! Claude Code would: `fable` → `Fable 5.2`, `opus[1m]` → `Opus 5.5 (1M +//! context)`. The stored id never changes; an id the catalog does not name +//! (a Bedrock ARN, a gateway path, any id on a machine with no catalog) +//! prints as it is. + +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{OnceLock, RwLock}; @@ -79,6 +104,7 @@ struct State { configured: Vec, available: Vec, installed: &'static [&'static str], + names: ModelNames, } static CURRENT: OnceLock> = OnceLock::new(); @@ -89,6 +115,7 @@ fn state() -> &'static RwLock { configured: Vec::new(), available: Vec::new(), installed: CLAUDE_MODELS, + names: ModelNames::default(), }) }) } @@ -275,7 +302,183 @@ pub fn available_models_from(cfg_dir: &Path, sys_dir: &Path) -> Option, + /// `claude-fable-5-2` → `Fable 5.2`. + names: HashMap, +} + +impl ModelNames { + /// The two tables out of one catalog cache document, in either shape + /// Claude Code writes: the served catalog (`catalog.config`) or the + /// signed fallback (`document.surfaces.cc.model_selector_config[]`). + /// `provider` picks an alias's `per_provider` target over its default. + /// None when the document is not the `cc` surface's or names no model. + pub fn in_catalog(doc: &serde_json::Value, provider: Option<&str>) -> Option { + let text = |v: &serde_json::Value, key: &str| -> Option { + Some(v.get(key)?.as_str()?.trim().to_string()).filter(|s| !s.is_empty()) + }; + let served = doc + .get("catalog") + .filter(|c| text(c, "surface").as_deref() == Some(CATALOG_SURFACE)) + .and_then(|c| c.get("config")); + let published = || { + doc.get("document")? + .get("surfaces")? + .get(CATALOG_SURFACE)? + .get("model_selector_config")? + .as_array()? + .iter() + .find(|c| text(c, "id").as_deref() == Some(CATALOG_SURFACE)) + }; + let config = served.or_else(published)?; + + let names: HashMap = config + .get("models")? + .as_array()? + .iter() + .filter_map(|m| Some((text(m, "id")?.to_ascii_lowercase(), text(m, "name")?))) + .collect(); + if names.is_empty() { + return None; + } + let aliases = config + .get("provider_alias_targets") + .and_then(|t| t.as_object()) + .map(|targets| { + targets + .iter() + .filter_map(|(alias, target)| { + let id = provider + .and_then(|p| text(target.get("per_provider")?, p)) + .or_else(|| text(target, "default"))?; + Some((alias.trim().to_ascii_lowercase(), id.to_ascii_lowercase())) + }) + .collect() + }) + .unwrap_or_default(); + Some(Self { aliases, names }) + } + + /// Claude Code's name for `model` — an alias or a full id, with or + /// without the `[1m]` context suffix, which reads `(1M context)` the + /// way `/model` prints it. None for an id the catalog does not name. + pub fn label(&self, model: &str) -> Option { + let lower = model.trim().to_ascii_lowercase(); + let (base, long) = match lower.strip_suffix("[1m]") { + Some(base) => (base.trim_end(), true), + None => (lower.as_str(), false), + }; + let id = self.aliases.get(base).map_or(base, String::as_str); + let name = self.names.get(id)?; + Some(if long { + format!("{name} (1M context)") + } else { + name.clone() + }) + } +} + +/// The provider Claude Code is pointed at, as the catalog's `per_provider` +/// tables key it; None on Anthropic's own API. A switch counts from the +/// environment or from the `env` block of the user's `settings.json`. +fn provider(cfg_dir: &Path) -> Option<&'static str> { + let settings = read_json(&cfg_dir.join("settings.json")); + let set = |key: &str| { + nebula_core::env::non_empty(key).is_some() + || settings + .as_ref() + .and_then(|s| s.get("env")?.get(key)?.as_str()) + .is_some_and(|v| !v.trim().is_empty()) + }; + PROVIDER_SWITCHES + .iter() + .find(|(key, _)| set(key)) + .map(|(_, provider)| *provider) +} + +/// The names in the newest catalog Claude Code has cached under `cfg_dir` +/// (by the `fetchedAt` each file records); empty when it has cached none. +pub fn names_from(cfg_dir: &Path, provider: Option<&str>) -> ModelNames { + let Ok(entries) = std::fs::read_dir(cfg_dir.join(MODEL_CATALOG_DIR)) else { + return ModelNames::default(); + }; + entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "json")) + .filter(|p| std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.len() <= CATALOG_MAX_BYTES)) + .filter_map(|path| { + let doc = read_json(&path)?; + let fetched = doc.get("fetchedAt").and_then(|v| v.as_u64()).unwrap_or(0); + Some((fetched, ModelNames::in_catalog(&doc, provider)?)) + }) + .max_by_key(|(fetched, _)| *fetched) + .map(|(_, names)| names) + .unwrap_or_default() +} + +/// Adopt Claude Code's names. Not called from tests: the view is +/// process-global, so a test's cards keep printing the ids they store. +pub fn install_names(names: ModelNames) { + let mut state = state() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.names != names { + state.names = names; + } +} + +/// Claude Code's name for a Claude model id, when its catalog names it: +/// `fable` → `Fable 5.2`. Callers print the id itself on None; they reach +/// this through [`crate::config::model_label`]. +pub fn label(model: &str) -> Option { + state() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .names + .label(model) +} + +/// TUI startup: adopt Claude Code's allowlist when one is on disk, and its +/// model names, which a background thread then keeps current. Skipped /// when Claude is switched off (HARNESS TOGGLE) and under `NEBULA_AGENT_CMD` /// (tests, STUB AGENTS), so no test reads the developer's real settings. pub fn bootstrap(claude_enabled: bool) { @@ -289,6 +492,13 @@ pub fn bootstrap(claude_enabled: bool) { tracing::info!("claude models from Claude Code's availableModels: {list:?}"); install_available(list); } + install_names(names_from(&cfg_dir, provider(&cfg_dir))); + // Off the event loop: the catalog is a directory of JSON files, and + // nothing on the drawing side may wait on a disk read. + std::thread::spawn(move || loop { + std::thread::sleep(NAMES_REFRESH); + install_names(names_from(&cfg_dir, provider(&cfg_dir))); + }); } #[cfg(test)] @@ -434,4 +644,164 @@ mod tests { Some(strings(&["from-20"])) ); } + + /// A served catalog cache file, cut down to the fields that are read. + fn served(fetched_at: u64, surface: &str, fable: (&str, &str)) -> serde_json::Value { + serde_json::json!({ + "version": 2, + "fetchedAt": fetched_at, + "catalog": {"surface": surface, "config": { + "id": surface, + "models": [ + {"id": "claude-opus-5-5", "name": "Opus 5.5", "short_name": "Opus"}, + {"id": fable.0, "name": fable.1, "short_name": "Fable"}, + {"id": "claude-opus-4-6", "name": "Opus 4.6"}, + {"id": "claude-nameless"}, + ], + "provider_alias_targets": { + "fable": {"default": fable.0}, + "opus": { + "default": "claude-opus-5-5", + "per_provider": {"foundry": "claude-opus-4-6", "mantle": "anthropic.opus"} + }, + "haiku": {"default": "claude-haiku-5-5"}, + }, + }}, + }) + } + + #[test] + fn a_model_reads_as_claude_code_lists_it() { + let doc = served(1, "cc", ("claude-fable-5-2", "Fable 5.2")); + let names = ModelNames::in_catalog(&doc, None).unwrap(); + // The alias a row stores, and the id it stands for. + assert_eq!(names.label("fable").as_deref(), Some("Fable 5.2")); + assert_eq!(names.label("opus").as_deref(), Some("Opus 5.5")); + assert_eq!( + names.label("claude-fable-5-2").as_deref(), + Some("Fable 5.2") + ); + assert_eq!( + names.label(" Claude-Opus-5-5 ").as_deref(), + Some("Opus 5.5") + ); + // The 1M window reads the way `/model` prints it. + assert_eq!( + names.label("opus[1m]").as_deref(), + Some("Opus 5.5 (1M context)") + ); + assert_eq!( + names.label("claude-fable-5-2[1M]").as_deref(), + Some("Fable 5.2 (1M context)") + ); + // Nothing the catalog does not name is renamed: "pass no flag", an + // id from elsewhere, an alias whose target has no entry, a model + // with no name. + for unnamed in [ + "default", + "us.anthropic.claude-sonnet-5-v1:0", + "haiku", + "sonnet", + "claude-nameless", + "", + ] { + assert_eq!(names.label(unnamed), None, "{unnamed:?}"); + } + } + + #[test] + fn an_alias_follows_the_provider_claude_code_is_pointed_at() { + let doc = served(1, "cc", ("claude-fable-5-2", "Fable 5.2")); + let on = |provider| ModelNames::in_catalog(&doc, provider).unwrap(); + assert_eq!(on(None).label("opus").as_deref(), Some("Opus 5.5")); + assert_eq!( + on(Some("foundry")).label("opus").as_deref(), + Some("Opus 4.6") + ); + // A provider the alias has no entry for takes the default. + assert_eq!( + on(Some("vertex")).label("opus").as_deref(), + Some("Opus 5.5") + ); + assert_eq!( + on(Some("foundry")).label("fable").as_deref(), + Some("Fable 5.2") + ); + // One whose target the catalog does not name keeps the alias. + assert_eq!(on(Some("mantle")).label("opus"), None); + } + + #[test] + fn the_signed_fallback_document_names_models_too() { + let published = serde_json::json!({ + "fetchedAt": 5, + "document": {"surfaces": { + "ccd": {"model_selector_config": [{"id": "ccd", "models": [ + {"id": "claude-fable-5-2", "name": "Desktop Fable"}, + ]}]}, + "cc": {"model_selector_config": [{"id": "cc", + "models": [{"id": "claude-fable-5-1", "name": "Fable 5.1"}], + "provider_alias_targets": {"fable": {"default": "claude-fable-5-1"}}, + }]}, + }}, + }); + let names = ModelNames::in_catalog(&published, None).unwrap(); + assert_eq!(names.label("fable").as_deref(), Some("Fable 5.1")); + + // Another surface's catalog, or a document with no models, is none. + let desktop = served(1, "ccd", ("claude-fable-5-2", "Fable 5.2")); + assert_eq!(ModelNames::in_catalog(&desktop, None), None); + let empty = serde_json::json!({"catalog": {"surface": "cc", "config": {"models": []}}}); + assert_eq!(ModelNames::in_catalog(&empty, None), None); + assert_eq!(ModelNames::in_catalog(&serde_json::json!({}), None), None); + } + + #[test] + fn names_come_from_the_newest_catalog_on_disk() { + let root = tempfile::tempdir().unwrap(); + let cfg_dir = root.path().join("claude"); + // No cache dir at all: nothing is named. + assert_eq!(names_from(&cfg_dir, None), ModelNames::default()); + + let dir = cfg_dir.join(MODEL_CATALOG_DIR); + std::fs::create_dir_all(&dir).unwrap(); + let write = |name: &str, doc: &serde_json::Value| { + std::fs::write(dir.join(name), doc.to_string()).unwrap(); + }; + write( + "tok-old-cc.json", + &served(100, "cc", ("claude-fable-5-1", "Fable 5.1")), + ); + assert_eq!( + names_from(&cfg_dir, None).label("fable").as_deref(), + Some("Fable 5.1") + ); + + // A later fetch wins whatever the file is called; the desktop + // surface, a broken file and a stray non-JSON one are passed over. + write( + "org-new-cc.json", + &served(300, "cc", ("claude-fable-5-2", "Fable 5.2")), + ); + write( + "tok-newest-ccd.json", + &served(900, "ccd", ("claude-fable-9", "Fable 9")), + ); + std::fs::write(dir.join("masked-ids.json"), r#"{"version":1,"ids":[]}"#).unwrap(); + std::fs::write(dir.join("torn-cc.json"), "{not json").unwrap(); + std::fs::write( + dir.join("notes.txt"), + served(999, "cc", ("claude-fable-9", "Fable 9")).to_string(), + ) + .unwrap(); + let names = names_from(&cfg_dir, None); + assert_eq!(names.label("fable").as_deref(), Some("Fable 5.2")); + assert_eq!(names.label("claude-fable-5-1"), None); + assert_eq!( + names_from(&cfg_dir, Some("foundry")) + .label("opus") + .as_deref(), + Some("Opus 4.6") + ); + } } diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index bb84cf3b..b5b7ce75 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -151,6 +151,28 @@ pub fn model_choices_in(descriptor: &nebula_core::harness::HarnessDescriptor) -> } } +/// What the UI calls a model: the name the harness's own picker lists it +/// under, where nebula knows one — Claude Code's `Fable 5.2` for the stored +/// `fable` (MODEL NAMES in `claude_catalogue.rs`) — else the id itself. +/// Display only: the id is still what is stored, matched and passed to the +/// CLI. Every surface that prints a session's or a launch's model goes +/// through here, so a card, a picker row and a settings row never disagree. +pub fn model_label(kind: AgentKind, model: &str) -> String { + (kind == AgentKind::Claude) + .then(|| crate::claude_catalogue::label(model)) + .flatten() + .unwrap_or_else(|| model.to_string()) +} + +/// [`model_label`] against an explicit descriptor, for callers that hold +/// one rather than a kind (the Agents tab). +pub fn model_label_in(descriptor: &nebula_core::harness::HarnessDescriptor, model: &str) -> String { + (descriptor.model.catalog == Some(nebula_core::harness::HarnessCatalog::Claude)) + .then(|| crate::claude_catalogue::label(model)) + .flatten() + .unwrap_or_else(|| model.to_string()) +} + /// Effort rows for a harness given its chosen model (None or "default" = /// the CLI's pick). Empty — no Effort row, no effort submenu — while the /// harness offers no effort. Cursor's list follows the family (`-fast` @@ -2167,7 +2189,7 @@ impl Config { let descriptor = self.effective_harness_by_id(id); match field { HarnessField::Enabled => on_off(descriptor.enabled).into(), - HarnessField::Model => descriptor.model.default.clone(), + HarnessField::Model => model_label_in(&descriptor, &descriptor.model.default), HarnessField::Effort => { let choices = effort_choices_in( &descriptor, diff --git a/crates/nebula-tui/src/event_loop/actions.rs b/crates/nebula-tui/src/event_loop/actions.rs index 5582b1ad..5e7aa076 100644 --- a/crates/nebula-tui/src/event_loop/actions.rs +++ b/crates/nebula-tui/src/event_loop/actions.rs @@ -749,11 +749,17 @@ pub(crate) fn build_submenu(item: &MenuItem) -> Option { let items: Vec = choices .iter() .map(|choice| { + // A model row reads as the harness's own picker lists it; the + // action below still carries the id. + let label = match sub { + SubmenuKind::Models => crate::config::model_label(*kind, choice), + SubmenuKind::Efforts => (*choice).to_string(), + }; MenuItem::new( if *choice == configured { - format!("{choice} ✓") + format!("{label} ✓") } else { - (*choice).to_string() + label }, MenuAction::NewAgentOfKind { worktree: worktree.clone(), diff --git a/crates/nebula-tui/src/preset_overlays.rs b/crates/nebula-tui/src/preset_overlays.rs index eafe3451..8097ec9f 100644 --- a/crates/nebula-tui/src/preset_overlays.rs +++ b/crates/nebula-tui/src/preset_overlays.rs @@ -1498,7 +1498,7 @@ pub(crate) fn draw_editor(f: &mut Frame, app: &mut App, editor: &AgentPresetEdit .as_deref() .unwrap_or_else(|| editor.kind.as_str()) .to_string(), - PresetField::Model => editor.model.clone(), + PresetField::Model => crate::config::model_label(editor.kind, &editor.model), PresetField::Effort => editor.effort.clone(), PresetField::Text => editor.text.as_str().to_string(), PresetField::Task => editor.task_choice().to_string(), diff --git a/crates/nebula-tui/src/quick_prompt.rs b/crates/nebula-tui/src/quick_prompt.rs index f551f3b0..f4671bb8 100644 --- a/crates/nebula-tui/src/quick_prompt.rs +++ b/crates/nebula-tui/src/quick_prompt.rs @@ -444,9 +444,13 @@ impl QuickLaunch { /// `Quick prompt (claude · cloud · opus)`. pub fn title(&self) -> String { let harness = self.custom.as_deref().unwrap_or_else(|| self.kind.as_str()); + let model = self + .model + .as_deref() + .map(|model| crate::config::model_label(self.kind, model)); let opts: Vec<&str> = std::iter::once(harness) .chain(self.cloud.then_some("cloud")) - .chain(self.model.as_deref()) + .chain(model.as_deref()) .chain(self.effort.as_deref()) .collect(); let mut head = vec!["Quick prompt".to_string()]; diff --git a/crates/nebula-tui/src/ui/launcher_view.rs b/crates/nebula-tui/src/ui/launcher_view.rs index 9b2306eb..461ff775 100644 --- a/crates/nebula-tui/src/ui/launcher_view.rs +++ b/crates/nebula-tui/src/ui/launcher_view.rs @@ -2546,7 +2546,7 @@ fn harness_line(a: &nebula_core::Agent, cfg: &mut Option) }; if let Some(model) = a.model.as_deref().filter(|m| !m.is_empty()) { out.push(' '); - out.push_str(model); + out.push_str(&crate::config::model_label(a.kind, model)); } out } @@ -3164,10 +3164,10 @@ impl Details { if launch.cloud { harness.push_str(" · cloud"); } - let mut model = launch - .model - .clone() - .unwrap_or_else(|| crate::config::DEFAULT_CHOICE.into()); + let mut model = launch.model.as_deref().map_or_else( + || crate::config::DEFAULT_CHOICE.into(), + |model| crate::config::model_label(launch.kind, model), + ); if let Some(effort) = launch.effort.as_deref().filter(|e| !e.is_empty()) { model.push(' '); model.push_str(effort); @@ -4262,6 +4262,101 @@ mod tests { assert_eq!(runs_on_line(&a, &mut None), "cloud high"); } + /// MODEL NAMES: a Claude model reads as Claude Code's own picker lists + /// it — `Fable 5.2`, not the stored `fable` — on the card, in the + /// QUICK PROMPT's details row and its title, and on a preset's line. + /// The stored id is untouched, and another harness's ids are never + /// renamed. + /// + /// The names are process-global, so this is the one test that installs + /// any, under ids no other test uses. + #[test] + fn a_claude_model_reads_as_claude_code_lists_it() { + let catalog = serde_json::json!({"catalog": {"surface": "cc", "config": { + "models": [{"id": "claude-wiring-9-9", "name": "Wiring 9.9"}], + "provider_alias_targets": {"wiring": {"default": "claude-wiring-9-9"}}, + }}}); + crate::claude_catalogue::install_names( + crate::claude_catalogue::ModelNames::in_catalog(&catalog, None).unwrap(), + ); + use nebula_core::AgentKind; + assert_eq!( + crate::config::model_label(AgentKind::Claude, "wiring"), + "Wiring 9.9" + ); + assert_eq!( + crate::config::model_label(AgentKind::Claude, "wiring[1m]"), + "Wiring 9.9 (1M context)" + ); + assert_eq!( + crate::config::model_label(AgentKind::Claude, "opus"), + "opus", + "an id the catalog does not name prints as it is" + ); + assert_eq!( + crate::config::model_label(AgentKind::Codex, "wiring"), + "wiring", + "the names are Claude Code's, not every harness's" + ); + let claude = nebula_core::harness::builtin("claude").unwrap(); + assert_eq!( + crate::config::model_label_in(&claude, "wiring"), + "Wiring 9.9" + ); + let codex = nebula_core::harness::builtin("codex").unwrap(); + assert_eq!(crate::config::model_label_in(&codex, "wiring"), "wiring"); + + // The session card. + let mut app = a_tree(); + select(&mut app, "api"); + app.tree.agents[0].model = Some("wiring".into()); + app.tree.agents[0].effort = Some("high".into()); + let lines = drawn_lines(&mut app, Rect::new(0, 0, 100, 30)); + let row = lines + .iter() + .position(|l| l.contains("s0")) + .expect("the session's card"); + assert!( + lines[row + 1].contains("claude Wiring 9.9 high"), + "what it runs on: {:?}", + lines[row + 1] + ); + assert_eq!( + app.tree.agents[0].model.as_deref(), + Some("wiring"), + "the row still stores what `--model` takes" + ); + + // The QUICK PROMPT: its details row and its title. + let mut launch = a_launch(); + launch.model = Some("wiring".into()); + launch.effort = Some("high".into()); + let line = detail_line(&app, &launch, BOX_SIZE.0 - 4, Theme::default()).line; + assert!( + text_of(&line).contains("Wiring 9.9 high"), + "the details row: {:?}", + text_of(&line) + ); + assert!( + launch.title().contains("Wiring 9.9"), + "the title: {:?}", + launch.title() + ); + + // A preset's line in the PRESET list. + let preset = crate::agent_presets::AgentPreset { + name: "p".into(), + kind: AgentKind::Claude, + custom_harness: None, + model: Some("wiring".into()), + effort: Some("high".into()), + prefix: String::new(), + postfix: String::new(), + skip_task: false, + }; + assert_eq!(preset.spec_label(), "claude · Wiring 9.9 · high"); + } + /// Every row of `body` as drawn with the view on it. fn drawn_lines(app: &mut App, body: Rect) -> Vec { let mut terminal = diff --git a/crates/nebula-tui/src/ui/launcher_view/nested.rs b/crates/nebula-tui/src/ui/launcher_view/nested.rs index 07af06d0..3ed9dd46 100644 --- a/crates/nebula-tui/src/ui/launcher_view/nested.rs +++ b/crates/nebula-tui/src/ui/launcher_view/nested.rs @@ -625,13 +625,11 @@ fn runs_on(a: &nebula_core::Agent, cfg: &mut Option) -> S a.kind.as_str().to_string() }; let mut parts = vec![harness]; - for part in [a.model.as_deref(), a.effort.as_deref()] - .into_iter() - .flatten() - { - if !part.is_empty() { - parts.push(part.to_string()); - } + if let Some(model) = a.model.as_deref().filter(|m| !m.is_empty()) { + parts.push(crate::config::model_label(a.kind, model)); + } + if let Some(effort) = a.effort.as_deref().filter(|e| !e.is_empty()) { + parts.push(effort.to_string()); } parts.join(" · ") } diff --git a/crates/nebula-tui/src/ui/overlay.rs b/crates/nebula-tui/src/ui/overlay.rs index 4c1e9e3c..c6028135 100644 --- a/crates/nebula-tui/src/ui/overlay.rs +++ b/crates/nebula-tui/src/ui/overlay.rs @@ -1070,7 +1070,7 @@ fn metrics_rows( let model = home .model .as_deref() - .map(|model| format!(" · {model}")) + .map(|model| format!(" · {}", crate::config::model_label(home.kind, model))) .unwrap_or_default(); let entry = kinds.entry("warm").or_default(); entry.0 += 1; diff --git a/docs/configuration.md b/docs/configuration.md index 0e7cc461..72e470f6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,7 +112,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `muse_enabled` | bool | `true` | Agents | HARNESS TOGGLE for Muse, same rules. Muse has no managed hooks yet, so its status stays process-based (running while the PTY is live, no waiting-on-you detection). | | `opencode_enabled` | bool | `true` | Agents | HARNESS TOGGLE for OpenCode, same rules. | | `hide_uninstalled_harnesses` | bool | `false` | Agents | When on, the NEW SESSION PICKER lists only enabled harnesses whose CLI is found on PATH. Off by default: a login shell can see CLIs a plain PATH lookup misses, and the daemon re-checks through the login shell at launch anyway. | -| `claude_model` | string | `"default"` | Agents | Default `--model` for new Claude sessions. The literal `"default"` is the sentinel meaning *don't pass the flag, let the CLI pick* — it is what you see in a fresh file, not a missing value. Overlay list: `fable`, `opus`, `sonnet`, `haiku` — unless `claude_models` below or Claude Code's own `availableModels` allowlist replaces it; any other string is passed through verbatim. | +| `claude_model` | string | `"default"` | Agents | Default `--model` for new Claude sessions. The literal `"default"` is the sentinel meaning *don't pass the flag, let the CLI pick* — it is what you see in a fresh file, not a missing value. Overlay list: `fable`, `opus`, `sonnet`, `haiku` — unless `claude_models` below or Claude Code's own `availableModels` allowlist replaces it; any other string is passed through verbatim. The file keeps the id; the UI shows the name Claude Code lists it under (`fable` reads `Fable 5.2`) when Claude Code's cached model catalog names it. | | `claude_models` | array of strings | `[]` | — (hand-edited) | The Claude model rows every picker offers (the NEW SESSION PICKER and QUICK PROMPT submenus, the AGENTS TAB, the PRESET EDITOR) in place of the built-in aliases, verbatim, `"default"` always first: `["claude-sonnet-5", "us.anthropic.claude-opus-5-v1:0"]`. For an organization that restricts models (Claude Code refuses `--model sonnet` with *Model "sonnet" is restricted by your organization's settings. Using claude-sonnet-5 instead.*) or a provider whose ids the aliases don't reach (Bedrock, Vertex, a gateway; on Bedrock `sonnet` even means Sonnet 4.5). Empty, the list follows Claude Code's `availableModels` when one is on disk — `~/.claude/remote-settings.json` (server-managed cache), the macOS MDM profile, `managed-settings.json` and `managed-settings.d/` in the system directory, then `~/.claude/settings.json`, read once at TUI start — else the aliases. A hand edit here applies without a restart. | | `claude_effort` | string | `"default"` | Agents | Default reasoning effort (`--effort`) for new Claude sessions: `low`, `medium`, `high`, `xhigh`, `max`, or the `"default"` sentinel. | | `codex_model` | string | `"default"` | Agents | Default `--model` for new Codex sessions. All model pickers follow Codex's catalog automatically: `$CODEX_HOME/models_cache.json` (default `~/.codex`) at startup, then `codex app-server`'s `model/list` in the background every 15 minutes. Hidden models are excluded. Failed discovery keeps the last catalog; `"default"` always lets the CLI choose. Hand-entered model ids still pass through. To use a static list instead, set `harnesses.codex.catalog` to `null` and supply `harnesses.codex.models`. | diff --git a/docs/how-it-works.md b/docs/how-it-works.md index aba5c4e7..8eb3a1fc 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -249,14 +249,24 @@ last wins; a name you set in nebula is never undone by re-reading Claude's older one. Claude only — Codex and Cursor have no session name of their own. - **A Claude session's card names the model it is on.** Switch with `/model` inside Claude Code and - the card follows within a second (`claude opus` → `claude fable`, `[1m]` kept for the 1M context - window). Claude fires no hook for that either; it writes `Set model to …` into the transcript at + the card follows within a second (`claude Opus 5.5` → `claude Fable 5.2`, `(1M context)` kept for + the 1M context window). Claude fires no hook for that either; it writes `Set model to …` into the transcript at once, and a `model` line naming the exact id with each session's first prompt. The DAEMON checks the size of every live session's transcript each second and reads only what was appended since. The new model is what the row stores, so a restart or resume comes back on it and `nebula spawn` inherits it. A session launched on the default model gets its model named after the first prompt. Built-in Claude only; a row on an exact id (a Bedrock id from `claude_models`) keeps it while Claude stays in that family. +- **A Claude model reads the way Claude Code lists it.** The row stores what `--model` takes — the + alias `fable`, or `opus[1m]` — and every surface that prints it (cards, the QUICK PROMPT, the + model submenu, the AGENTS TAB, presets) prints Claude Code's own name instead: `Fable 5.2`, + `Opus 5.5 (1M context)`. The names come from the catalog Claude Code's `/model` picker is drawn + from, which it caches under `~/.claude/cache/model-catalog/`; the TUI reads the newest file at + start and again every ten minutes, so a new release is named without an update to nebula. An + alias follows the provider Claude Code is pointed at (`CLAUDE_CODE_USE_BEDROCK` and its siblings, + from the environment or `settings.json`'s `env`). An id the catalog does not name — a Bedrock + ARN, a gateway path, anything on a machine where Claude Code has cached no catalog — prints as + it is. - **Cards show what they were last asked.** The `prompt` field of the `UserPromptSubmit` payload — which every hooked harness sends — is condensed to one line in the hook receiver, kept on the AGENT row (the newest ten, in SQLite), and the newest is drawn on the session's card with an ago label.