Skip to content
Merged
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
6 changes: 5 additions & 1 deletion crates/nebula-tui/src/agent_presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(" · ")
}
Expand Down
374 changes: 372 additions & 2 deletions crates/nebula-tui/src/claude_catalogue.rs

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion crates/nebula-tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions crates/nebula-tui/src/event_loop/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,11 +749,17 @@ pub(crate) fn build_submenu(item: &MenuItem) -> Option<ContextMenu> {
let items: Vec<MenuItem> = 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(),
Expand Down
2 changes: 1 addition & 1 deletion crates/nebula-tui/src/preset_overlays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 5 additions & 1 deletion crates/nebula-tui/src/quick_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
Expand Down
105 changes: 100 additions & 5 deletions crates/nebula-tui/src/ui/launcher_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2546,7 +2546,7 @@ fn harness_line(a: &nebula_core::Agent, cfg: &mut Option<crate::config::Config>)
};
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
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<String> {
let mut terminal =
Expand Down
12 changes: 5 additions & 7 deletions crates/nebula-tui/src/ui/launcher_view/nested.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,13 +625,11 @@ fn runs_on(a: &nebula_core::Agent, cfg: &mut Option<crate::config::Config>) -> 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(" · ")
}
Expand Down
2 changes: 1 addition & 1 deletion crates/nebula-tui/src/ui/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
14 changes: 12 additions & 2 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading