From 73871cb1dc575fcd06dc1ac9612bd6b02faba5d0 Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 27 Aug 2026 13:06:21 +0200 Subject: [PATCH] Run every model at the 1M context window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window is a beta header, so nothing on disk records it. Observing it on the wire and replaying it on resume had to survive a session rename, a hot reload, a WebSearch sub-request and a process restart — every gap fell back to the short window. Force it instead: Models::arg is the single decision point, and every spawn and resume goes through it. A model is still only suffixed when the installed claude ships that variant, so haiku stays haiku. --- CLAUDE.md | 91 +++++++------ src/app.rs | 364 +++++++++++++++++++++++++-------------------------- src/proxy.rs | 21 +-- src/term.rs | 77 +++++------ src/ui.rs | 32 +++-- 5 files changed, 291 insertions(+), 294 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2bfe6e0..a87da00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,8 @@ src/sessions.rs on-disk session history (main chain *and* subagents): App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on mtime change — one pass yields the label *and* the session's last main-chain model, which `App::resume_model` turns into the - `--model` a resume spawns with); load_view/load_history rebuild a feed Session + `--model` a resume spawns with — always at `[1m]`); + load_view/load_history rebuild a feed Session from a JSONL transcript (lazily, on first view) and give it the same `` treatment as the live path — notes lifted into Kind::TaskNote, the `` moved onto the `Agent` @@ -145,8 +146,9 @@ src/term.rs embedded claude pane: spawns `claude --session-id ` in a `x-claude-cloak-pane`), the correlation handle the proxy uses to recognise the pane's own traffic (see the embed-identity invariant). `cc_default_model` reads Claude Code's *own* configured default - model out of its settings — the only persisted record of a - `[1m]` pick (see the 1M-context invariant). `EmbeddedTerm::adopt` + model out of its settings, and `spawn_model_discovery` scans the + `claude` binary for `App::models` (the aliases, and which of them + ship a `[1m]` variant — see the 1M invariant). `EmbeddedTerm::adopt` rebuilds a pane around an inherited pty fd + pid after a hot reload (`AdoptedMaster` / `PidKiller` stand in for the portable-pty handles, which do not survive an exec) @@ -276,9 +278,7 @@ UI thread redraws on its own tick (no channel; just the mutex). is labelled `web_search · ""`, finished in `Tap::drop` (one request = one turn), emits no system/tools/side-request lines, and is readable only in the `A` popup — never in the main feed. Treating it as a turn start used to - push a fake user prompt, clobber the main lane's system/tools signatures, and - **silently downgrade a `[1m]` session to the short window** on the next - resume. + push a fake user prompt and clobber the main lane's system/tools signatures. - **The trailing user run skips `role:"system"` messages.** Claude Code ≥2.1.247 sends beta `mid-conversation-system-2026-04-07` and appends the agent-type listing as a `role:"system"` message *after* the prompt, so turn 1 of every @@ -433,31 +433,34 @@ agentId: `), and the real completion is injected into the parent's next last main-chain assistant message (`DiskSession::model`, filled by the scanner's `read_meta` — subagent `isSidechain` records run their own model and `` error records carry no model, so both are skipped) and - `app::model_arg_for_id` maps that id to a `--model` argument: a known alias - (`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot + `Models::base_for_id` maps that id to a base model name: a known alias + (`sonnet`, `opus`, … from `Models::aliases`) wins over the dated snapshot id, so a retired snapshot can't pin the pane; an id with no alias inside is passed through verbatim (`--model` takes full names too). The transcript is - authoritative, so a mid-session `/model` switch is honoured. -- **The 1M context window is a header, and a resume must keep it.** - `--model opus[1m]` differs from `opus` only by `anthropic-beta: - …,context-1m-…` — same body `model`, same transcript record — so no amount of - transcript reading can tell them apart. The proxy is the only place that - sees it: `proxy.rs` reads `BETA_HEADER` on **main-chain turn requests only** - — gated on `ReqKind::Turn && MAIN_LANE`, because a side/title call runs haiku - without the flag, a subagent runs its own model, and a nested server-tool call - never carries the flag at all — and `app::record_long_context` stores it as - `Session::long_context`. - `App::resume_arg` then picks the window: the wire observation wins, else the - `[1m]` in `Session::spawn_model` (our own spawn, while it still names the - same model), and with **neither** — a session that predates this process — - it falls back to `term::cc_default_model()`, Claude Code's configured default - (`ANTHROPIC_MODEL`, then local/project/user `settings.json`), which is the - one place a `[1m]` pick is persisted (`/model` writes it there). When that - default names the same base model the resume passes **no `--model` at all** - and inherits it whole, window included; any explicit knowledge overrides it, - including "this session ran the *short* window", which is why an observed - non-1m session is resumed with an explicit `--model opus`. The suffix is only - ever added for an alias that `model_choices` says has a `[1m]` variant. + authoritative, so a mid-session `/model` switch is honoured. Only the + *model* is decided there — the window is not a question (see below). +- **Every model runs the 1M context window, always.** `Models::arg` is the one + place that decides and it answers `[1m]` whenever the installed + `claude` ships that variant (`Models::long`, read out of the binary — the + suffix is never assumed, so `haiku` stays `haiku`). Every spawn goes through + it: `App::spawn_arg` for a fresh pane and the `a` picker, `App::resume_arg` + for a resume. So a resume is `--model [1m]` and nothing infers a + window any more. + The window is a *header*, not a model: `--model opus[1m]` differs from + `opus` only by `anthropic-beta: …,context-1m-…`, and the body `model` and + the transcript record read identically either way. That is why the old + "observe it on the wire, replay it on resume" design existed — and why it + kept losing the window: the observation had to survive a rename, a reload, + a `WebSearch` sub-request and a process restart, and any gap fell back to + the short window. Forcing it removes the failure mode instead of patching + it. `Session::long_context`, `record_long_context` and the proxy's + `anthropic-beta` read are gone with it. + A model still has to be *named* for the suffix to attach, so a session we + know nothing about (no transcript model, no spawn argument) borrows the + model — never the window — from `term::cc_default_model()` + (`ANTHROPIC_MODEL`, then local/project/user `settings.json`). With no + default configured either, the spawn passes no `--model` at all and + `claude` picks both. - **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel): `space` (or `→`/`l`) expands the selected session's turn tree — one row per real user prompt, abandoned rewind branches indented `⑂` under their fork @@ -593,19 +596,23 @@ agentId: `), and the real completion is injected into the parent's next enter/leave the turn tree, a opens the model picker popup and spawns a brand-new `claude --session-id … [--model …]` (kills any current pane — `show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker - list comes from `App::model_choices`: seeded with `default_model_choices`, - then replaced by `term::spawn_model_discovery` — a background scan that - reads the live model-alias array (`["sonnet","opus","haiku","fable",…]`) - straight out of the installed `claude` ELF (single self-contained binary - with the JS bundle embedded). No API call, never runs claude — just resolves - `claude` on PATH and greps its bytes for the longest lowercase-token array - anchored by `opus`+`sonnet`. The list then gets the **1M-context variants** - appended (`sonnet[1m]` etc., Claude Code's `--model` spelling for the long - context window): `term::long_context_tokens` collects every quoted - `"[1m]"` literal in the same byte scan, and only aliases that really - have one are offered (today `opus`/`sonnet`/`fable` — *not* `haiku` or - `mythos`), so the suffix is never assumed. `[1m]` needs no shell quoting: - the pane spawns via `CommandBuilder` argv, not a shell. + list is `Models::choices()` — one row per model, at the window + `Models::arg` gives it (`sonnet (1M context)` → `sonnet[1m]`, `haiku` → + `haiku`), plus a `default` row that `App::spawn_arg` resolves to Claude + Code's own configured model. There is deliberately **no short-window row**: + every model runs 1M (see the invariant), so offering one would be a lie. + `App::models` is seeded by `Models::seed` and replaced by + `term::spawn_model_discovery` — a background scan that reads the live + model-alias array (`["sonnet","opus","haiku","fable",…]`) straight out of + the installed `claude` ELF (single self-contained binary with the JS bundle + embedded). No API call, never runs claude — just resolves `claude` on PATH + and greps its bytes for the longest lowercase-token array anchored by + `opus`+`sonnet`. The same pass collects every quoted `"[1m]"` literal + (`term::long_context_tokens`) and keeps the base names as `Models::long`, so + only models that really have the variant get the suffix — today + `opus`/`sonnet`/`fable` and a set of full ids, *not* `haiku` or `mythos`. + `[1m]` needs no shell quoting: the pane spawns via `CommandBuilder` argv, + not a shell. Tab/BackTab cycle sessions (`p` no longer mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree → quit). n/N jump the feed scroll to the next/previous user prompt diff --git a/src/app.rs b/src/app.rs index e236b8b..0a80fbe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,55 +16,6 @@ pub fn lock_app(app: &SharedApp) -> std::sync::MutexGuard<'_, App> { pub const FILTER_LABELS: [&str; 7] = ["user", "thinking", "text", "tools", "meta", "errors", "system"]; -/// Fallback model picker entries used until (or unless) `term::discover_models` -/// reads the live alias set out of the installed `claude` binary. Each entry is -/// `(label, --model arg)`; an empty arg means no `--model` flag (Claude Code's -/// configured default). -/// -/// The `[1m]` entries are Claude Code's spelling for the 1M-context -/// variant of a model (only some aliases have one — `haiku` does not). The live -/// set is verified against the binary by `term::long_context_tokens`; these are -/// just the seeds. -pub fn default_model_choices() -> Vec<(String, String)> { - [ - ("default", ""), - ("opus", "opus"), - ("sonnet", "sonnet"), - ("haiku", "haiku"), - ("fable", "fable"), - ("opus[1m] (1M context)", "opus[1m]"), - ("sonnet[1m] (1M context)", "sonnet[1m]"), - ("fable[1m] (1M context)", "fable[1m]"), - ] - .iter() - .map(|(l, a)| (l.to_string(), a.to_string())) - .collect() -} - -/// Map an API model id (as recorded in a transcript, e.g. -/// `claude-sonnet-4-5-20250929`) to a `--model` argument for `claude`. -/// -/// A known alias (`sonnet`, `opus`, … — the same list the `a` picker uses) -/// wins over the dated snapshot id: it keeps the intent ("this session ran on -/// sonnet") without pinning a snapshot that may later be retired. An id with -/// no alias inside is passed through verbatim (`--model` accepts full model -/// names too). Placeholders Claude Code writes for its own records -/// (``) and our `(resumed)` filler yield None. -pub fn model_arg_for_id(id: &str, aliases: &[String]) -> Option { - let id = id.trim(); - if id.is_empty() || id.starts_with('<') || id.starts_with('(') { - return None; - } - // Longest match wins, so a short alias can never shadow a longer one. - let mut hit: Option<&String> = None; - for a in aliases.iter().filter(|a| !a.is_empty() && !a.contains('[')) { - if id.contains(a.as_str()) && hit.is_none_or(|h| a.len() > h.len()) { - hit = Some(a); - } - } - Some(hit.map_or_else(|| id.to_string(), String::clone)) -} - /// Claude Code's suffix for the 1M-context variant of a model (`sonnet[1m]`). pub const LONG_SUFFIX: &str = "[1m]"; @@ -73,6 +24,92 @@ pub fn base_model(arg: &str) -> &str { arg.split_once('[').map_or(arg, |(b, _)| b) } +/// The models the installed `claude` accepts — facts, not picker rows. +/// Seeded by `Models::seed` and replaced by `term::spawn_model_discovery`, +/// which reads both lists out of the `claude` binary. +/// +/// **Every model runs the 1M context window.** `Models::arg` is the single +/// place that decides, and it always answers `[1m]` when the binary +/// ships that variant. Nothing infers the window any more: the wire +/// observation, the spawn argument and Claude Code's configured default only +/// ever named a *model*, never a window we had to keep. +pub struct Models { + /// Plain aliases in the order `claude` lists them (`opus`, `sonnet`, …). + pub aliases: Vec, + /// Base names that really ship a `[1m]` variant (`haiku` does not), so the + /// suffix is never assumed. Aliases *and* full model ids, because a + /// transcript can name a model no alias covers. + pub long: Vec, +} + +impl Models { + /// Fallback catalog used until (or unless) the binary scan succeeds. + pub fn seed() -> Self { + let v = |xs: &[&str]| xs.iter().map(|s| (*s).to_string()).collect(); + Self { + aliases: v(&["opus", "sonnet", "haiku", "fable"]), + long: v(&["opus", "sonnet", "fable"]), + } + } + + /// The `--model` argument for `base`: its 1M variant when one exists, + /// else `base` unchanged. Any `[1m]` already on `base` is re-derived, so + /// passing an argument back through is a no-op. An empty `base` stays + /// empty (= pass no `--model` flag). + pub fn arg(&self, base: &str) -> String { + let base = base_model(base.trim()); + if base.is_empty() { + return String::new(); + } + if self.long.iter().any(|l| l == base) { + format!("{base}{LONG_SUFFIX}") + } else { + base.to_string() + } + } + + /// `a` picker rows `(label, --model arg)`, one per model: `default` (no + /// flag — resolved to Claude Code's own default by `App::spawn_arg`) then + /// every alias at the window `arg` gives it. There is no short-window row; + /// offering one would contradict the policy above. + pub fn choices(&self) -> Vec<(String, String)> { + let mut rows = vec![("default".to_string(), String::new())]; + rows.extend(self.aliases.iter().map(|a| { + let arg = self.arg(a); + // The label says which window this row really spawns, so a model + // without a `[1m]` variant can't look like it has one. + let label = + if arg == *a { a.clone() } else { format!("{a} (1M context)") }; + (label, arg) + })); + rows + } + + /// Map an API model id (as recorded in a transcript, e.g. + /// `claude-sonnet-4-5-20250929`) to a base model name. + /// + /// A known alias (`sonnet`, `opus`, …) wins over the dated snapshot id: it + /// keeps the intent ("this session ran on sonnet") without pinning a + /// snapshot that may later be retired. An id with no alias inside is passed + /// through verbatim (`--model` accepts full model names too). Placeholders + /// Claude Code writes for its own records (``) and our + /// `(resumed)` filler yield None. + pub fn base_for_id(&self, id: &str) -> Option { + let id = id.trim(); + if id.is_empty() || id.starts_with('<') || id.starts_with('(') { + return None; + } + // Longest match wins, so a short alias can never shadow a longer one. + let mut hit: Option<&String> = None; + for a in self.aliases.iter().filter(|a| !a.is_empty()) { + if id.contains(a.as_str()) && hit.is_none_or(|h| a.len() > h.len()) { + hit = Some(a); + } + } + Some(hit.map_or_else(|| id.to_string(), String::clone)) + } +} + pub struct App { pub sessions: Vec, pub selected: usize, @@ -84,12 +121,12 @@ pub struct App { /// `Some(selected_row)` while the filter popup is open. pub filter_popup: Option, /// `Some(selected_row)` while the `n` model-picker popup is open; picking a - /// model spawns a fresh embedded session (indexes `model_choices`). + /// model spawns a fresh embedded session (indexes `Models::choices`). pub model_popup: Option, - /// Model picker entries `(label, --model arg)`. Seeded with - /// `default_model_choices`, then replaced by `term::discover_models` once a - /// background scan reads the live alias set from the `claude` binary. - pub model_choices: Vec<(String, String)>, + /// The models the installed `claude` accepts. Seeded with `Models::seed`, + /// then replaced by `term::spawn_model_discovery` once a background scan + /// reads the live sets from the `claude` binary. + pub models: Models, /// Whether the session list panel is expanded. pub show_sessions: bool, /// Past sessions on disk for this directory (newest first), maintained by @@ -246,7 +283,7 @@ impl App { filters: [true; FILTER_LABELS.len()], filter_popup: None, model_popup: None, - model_choices: default_model_choices(), + models: Models::seed(), show_sessions: false, disk_sessions: Vec::new(), history: HashMap::new(), @@ -288,8 +325,8 @@ impl App { } /// The `--model` argument a resume of `key` should spawn with, so the pane - /// continues on the model *and context window* that session last used - /// instead of the CLI default. Empty string = pass no `--model` flag. + /// continues on the model that session last used — at the 1M window, like + /// every other spawn. Empty string = pass no `--model` flag. /// /// Reads `term::cc_default_model()` (Claude Code's own configured default); /// `resume_arg` holds the decision itself. @@ -300,51 +337,42 @@ impl App { /// The resume decision, with Claude Code's configured default passed in /// (so it is testable without touching settings files). /// - /// *Which model*: the on-disk transcript wins — Claude Code records the + /// Only the **model** is decided here; `Models::arg` picks the window and + /// always picks 1M. The on-disk transcript wins — Claude Code records the /// model of every assistant message, so a mid-session `/model` switch is /// visible there. The pane's own spawn argument covers a session that never - /// answered. - /// - /// *Which window*: `Session::long_context`, observed on the wire, is the - /// only exact answer (nothing on disk distinguishes `opus` from - /// `opus[1m]`); the spawn argument answers for a pane we started ourselves. - /// With neither — a session from before this process — Claude Code's - /// configured default is the one other place a `[1m]` choice is written - /// down, so when it names this same model we pass **no flag at all** and - /// let the child apply that default, window and all. Any explicit answer - /// overrides it, including "this session ran the *short* window". + /// answered, and Claude Code's configured default covers a session we know + /// nothing about: naming it explicitly is what lets us add the `[1m]`, + /// which passing no flag at all never could. fn resume_arg(&self, key: &str, cc_default: Option<&str>) -> String { - let aliases: Vec = - self.model_choices.iter().map(|(_, a)| a.clone()).collect(); - let sess = self.sessions.iter().find(|s| s.key == key); - let spawned = sess + let spawned = self + .sessions + .iter() + .find(|s| s.key == key) .and_then(|s| s.spawn_model.clone()) .filter(|m| !m.is_empty()); - let Some(base) = self + let base = self .disk_sessions .iter() .find(|d| d.uuid == key) - .and_then(|d| model_arg_for_id(&d.model, &aliases)) + .and_then(|d| self.models.base_for_id(&d.model)) .or_else(|| spawned.as_deref().map(|s| base_model(s).to_string())) - else { - return String::new(); - }; - let long = sess.and_then(|s| s.long_context).or_else(|| { - spawned - .as_deref() - .filter(|s| base_model(s) == base) - .map(|s| s.ends_with(LONG_SUFFIX)) - }); - match long { - Some(true) => { - let long_arg = format!("{base}{LONG_SUFFIX}"); - // Only offer the suffix for a model that really has one. - if aliases.contains(&long_arg) { long_arg } else { base } - } - Some(false) => base, - None if cc_default.is_some_and(|d| base_model(d) == base) => String::new(), - None => base, + .or_else(|| cc_default.map(|d| base_model(d).to_string())); + base.map(|b| self.models.arg(&b)).unwrap_or_default() + } + + /// The `--model` argument a *fresh* pane spawns with. `pick` is the picker + /// row's argument, already at the 1M window (`Models::choices`); the empty + /// `default` row resolves to Claude Code's own configured model so the + /// suffix has something to attach to. Empty only when nothing configures a + /// default — then `claude` picks the model, and the window with it. + pub fn spawn_arg(&self, pick: &str) -> String { + if !pick.is_empty() { + return self.models.arg(pick); } + crate::term::cc_default_model() + .map(|d| self.models.arg(&d)) + .unwrap_or_default() } /// Remember the `--model` argument the pane for `key` was spawned with @@ -851,9 +879,7 @@ pub const MAIN_LANE: LaneId = 0; /// request used to look exactly like a turn start (a non-empty `tools` array), /// which pushed its `Perform a web search for the query: …` message into the /// main feed as a real prompt, clobbered the lane's system/tools signatures, -/// and — worst — ran `record_long_context` with a header set that never carries -/// the `context-1m` beta, silently downgrading a session that really ran -/// `opus[1m]` to the short window on the next resume. +/// and pushed a fake user prompt into the main chain. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ReqKind { /// A real turn: at least one *client* tool definition is on offer. @@ -1156,17 +1182,9 @@ pub struct Session { /// The `--model` argument the embedded pane for this session was spawned /// with (`None` for external sessions, `Some("")` for the CLI default). /// In-process memory only; it travels with the row when the tap renames - /// the key. `resume_model` uses it to keep a `[1m]` pick across a resume. + /// the key. `resume_model` reads it for the *model* of a session that has + /// no transcript yet — never for the window, which is always 1M. pub spawn_model: Option, - /// Whether the main chain runs with the **1M-context window**, observed on - /// the wire (`anthropic-beta: …context-1m…`, what a `[1m]` model - /// sends). `None` until a main-chain turn request passed through us. - /// - /// The window is a beta header, not a model id: the body model and the - /// transcript record read exactly the same for `opus` and `opus[1m]`, so - /// this observation is the only fact that lets a resume keep — or - /// deliberately drop — the long window (`resume_model`). - pub long_context: Option, } impl Default for Session { @@ -1188,7 +1206,6 @@ impl Session { last_activity: Instant::now(), tool_ids: HashMap::new(), spawn_model: None, - long_context: None, } } @@ -1991,16 +2008,6 @@ pub(crate) fn strip_injected(t: &str) -> String { .to_string() } -/// Record the main chain's context window for `key`, learned from a request's -/// `anthropic-beta` header (see `proxy::LONG_CONTEXT_BETA`). Called per -/// main-chain turn request, so a mid-session window switch is picked up. -pub fn record_long_context(app: &SharedApp, key: &str, long: bool) { - let mut a = lock_app(app); - if let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) { - s.long_context = Some(long); - } -} - /// Display name of a hosted tool: its `name` (`web_search`) when the request /// gives one, else its versioned `type` with the trailing `_` trimmed /// (`web_search_20250305` → `web_search`), so the label never carries a version @@ -2914,10 +2921,6 @@ mod tests { use serde_json::json; use std::time::Duration; - fn aliases() -> Vec { - default_model_choices().into_iter().map(|(_, a)| a).collect() - } - /// A disk stub with a recorded model, as the scanner produces it. fn stub(uuid: &str, model: &str) -> crate::sessions::DiskSession { crate::sessions::DiskSession { @@ -2931,84 +2934,83 @@ mod tests { #[test] fn model_id_maps_to_its_alias() { - let al = aliases(); - let arg = |id: &str| model_arg_for_id(id, &al); - assert_eq!(arg("claude-sonnet-4-5-20250929").as_deref(), Some("sonnet")); - assert_eq!(arg("claude-opus-4-5-20251101").as_deref(), Some("opus")); - assert_eq!(arg("claude-opus-5").as_deref(), Some("opus")); - assert_eq!(arg("claude-3-5-haiku-20241022").as_deref(), Some("haiku")); + let m = Models::seed(); + let base = |id: &str| m.base_for_id(id); + assert_eq!(base("claude-sonnet-4-5-20250929").as_deref(), Some("sonnet")); + assert_eq!(base("claude-opus-4-5-20251101").as_deref(), Some("opus")); + assert_eq!(base("claude-opus-5").as_deref(), Some("opus")); + assert_eq!(base("claude-3-5-haiku-20241022").as_deref(), Some("haiku")); // No known alias inside → the full id (accepted by `--model` too). - assert_eq!(arg("claude-mythos-1-20260101").as_deref(), Some("claude-mythos-1-20260101")); + assert_eq!( + base("claude-mythos-1-20260101").as_deref(), + Some("claude-mythos-1-20260101") + ); // Placeholders yield nothing: no `--model` flag at all. - assert_eq!(arg(""), None); - assert_eq!(arg("(resumed)"), None); - assert_eq!(arg(""), None); + assert_eq!(base(""), None); + assert_eq!(base("(resumed)"), None); + assert_eq!(base(""), None); + } + + #[test] + fn every_model_argument_carries_the_long_window() { + let m = Models::seed(); + assert_eq!(m.arg("opus"), "opus[1m]"); + // Already suffixed → unchanged, so an argument can round-trip. + assert_eq!(m.arg("opus[1m]"), "opus[1m]"); + // A model with no `[1m]` variant never grows one. + assert_eq!(m.arg("haiku"), "haiku"); + // Nor does a full id the binary never listed a variant for. + assert_eq!(m.arg("claude-mythos-1-20260101"), "claude-mythos-1-20260101"); + // Empty stays empty: that is "pass no `--model` flag". + assert_eq!(m.arg(""), ""); } #[test] fn resume_model_prefers_the_transcript() { let mut a = App::new(); a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")]; - assert_eq!(a.resume_arg("s1", None), "opus"); - // Unknown session → no flag. + assert_eq!(a.resume_arg("s1", None), "opus[1m]"); + // Unknown session, and no configured default to borrow a model from → + // no flag; claude picks for itself. assert_eq!(a.resume_arg("nope", None), ""); } #[test] - fn resume_model_keeps_a_1m_pick_until_the_model_changes() { + fn resume_model_falls_back_to_the_spawn_argument() { let mut a = App::new(); a.sessions.push(Session::new("s1".into(), "sonnet".into())); a.set_spawn_model("s1", "sonnet[1m]"); - // Transcript agrees on the model → the long-context spelling survives - // (the transcript records the base id for both). + // Transcript agrees on the model. a.disk_sessions = vec![stub("s1", "claude-sonnet-4-5-20250929")]; assert_eq!(a.resume_arg("s1", None), "sonnet[1m]"); - // A mid-session `/model` switch shows up on disk and wins — and takes - // the stale `[1m]` memory with it. + // A mid-session `/model` switch shows up on disk and wins. a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")]; - assert_eq!(a.resume_arg("s1", None), "opus"); - // No transcript model yet (spawned, never answered) → the spawn arg. + assert_eq!(a.resume_arg("s1", None), "opus[1m]"); + // No transcript model yet (spawned, never answered) → the spawn arg, + // whose window is re-derived rather than remembered. a.disk_sessions.clear(); + a.set_spawn_model("s1", "sonnet"); assert_eq!(a.resume_arg("s1", None), "sonnet[1m]"); - // The CLI default is remembered as "no flag", not as a model. - a.set_spawn_model("s1", ""); + } + + #[test] + fn a_session_we_know_nothing_about_still_gets_the_long_window() { + // Nothing on disk, nothing spawned: name Claude Code's own default + // model explicitly, because only a named model can carry `[1m]`. + let a = App::new(); + assert_eq!(a.resume_arg("s1", Some("opus")), "opus[1m]"); + assert_eq!(a.resume_arg("s1", Some("opus[1m]")), "opus[1m]"); + assert_eq!(a.resume_arg("s1", Some("haiku")), "haiku"); assert_eq!(a.resume_arg("s1", None), ""); } #[test] - fn resume_model_follows_the_observed_context_window() { - let mut a = App::new(); - a.sessions.push(Session::new("s1".into(), "opus".into())); - a.disk_sessions = vec![stub("s1", "claude-opus-5")]; - // Wire says 1M → resume on the long-context spelling. - a.sessions[0].long_context = Some(true); - assert_eq!(a.resume_arg("s1", None), "opus[1m]"); - // Wire wins over a stale spawn argument, both ways round. - a.set_spawn_model("s1", "opus"); - assert_eq!(a.resume_arg("s1", None), "opus[1m]"); - a.sessions[0].long_context = Some(false); - a.set_spawn_model("s1", "opus[1m]"); - assert_eq!(a.resume_arg("s1", None), "opus"); - // …and an explicit "short window" beats a 1M default: we know better. - assert_eq!(a.resume_arg("s1", Some("opus[1m]")), "opus"); - // A model with no `[1m]` variant never grows one. - a.disk_sessions = vec![stub("s1", "claude-haiku-4-5-20251001")]; - a.sessions[0].long_context = Some(true); - a.sessions[0].spawn_model = None; - assert_eq!(a.resume_arg("s1", None), "haiku"); - } - - #[test] - fn unobserved_session_inherits_claude_codes_own_default() { - // A session from before this process: nothing knows its window, so the - // same-model default is inherited whole (no `--model` flag) rather than - // pinned to the short window. - let mut a = App::new(); - a.disk_sessions = vec![stub("s1", "claude-opus-5")]; - assert_eq!(a.resume_arg("s1", Some("opus[1m]")), ""); - assert_eq!(a.resume_arg("s1", Some("opus")), ""); - // A default for a *different* model can't speak for this session. - assert_eq!(a.resume_arg("s1", Some("sonnet[1m]")), "opus"); + fn spawn_argument_resolves_the_default_picker_row() { + let a = App::new(); + // An explicit pick is honoured, at the long window. + assert_eq!(a.spawn_arg("sonnet"), "sonnet[1m]"); + assert_eq!(a.spawn_arg("sonnet[1m]"), "sonnet[1m]"); + assert_eq!(a.spawn_arg("haiku"), "haiku"); } /// Wire fixture: a subagent's request, identified by Claude Code's own @@ -3346,8 +3348,8 @@ mod tests { /// Anthropic's *server-side* web_search tool under the parent's own /// session id and with no agent-id header. It gets its own lane (readable /// in the `A` popup, invisible to the main feed) and must not touch a - /// single piece of main-chain state — above all not the observed 1M - /// context window, which it would otherwise clear. + /// single piece of main-chain state — neither the feed nor the lane's + /// system/tools signatures. #[test] fn server_tool_request_gets_its_own_lane_and_never_touches_the_main_chain() { let app: SharedApp = Arc::new(Mutex::new(App::new())); @@ -3368,11 +3370,10 @@ mod tests { {"name": "WebSearch", "input_schema": {}}, ], "messages": [{"role": "user", "content": "search the web for me"}]}), ); - record_long_context(&app, "s", true); - // The nested hosted-tool request. `classify_request` is what gates - // `record_long_context` / the system+tools lines in the proxy, so the - // classification *is* the fix; the rest of the pipeline runs verbatim. + // The nested hosted-tool request. `classify_request` is what gates the + // system+tools lines in the proxy, so the classification *is* the fix; + // the rest of the pipeline runs verbatim. let body = json!({ "model": "claude-opus-5", "stream": true, @@ -3403,9 +3404,6 @@ mod tests { let a = app.lock().unwrap(); let s = &a.sessions[0]; - // The one that matters: a session that really ran `opus[1m]` must still - // resume with the long window after a web search. - assert_eq!(s.long_context, Some(true), "the observed 1M window survives"); // Main-chain signatures are untouched, so the next real turn does not // re-emit its system prompt / tool list. assert_eq!(s.main().last_system_len, Some(10)); diff --git a/src/proxy.rs b/src/proxy.rs index 8784029..a553f11 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,7 +1,6 @@ use crate::app::{ AgentTag, ReqKind, SharedApp, Tap, attach_tool_results, classify_request, - label_server_tool_lane, lock_app, next_server_tool_id, record_long_context, - record_user_prompt, + label_server_tool_lane, lock_app, next_server_tool_id, record_user_prompt, }; use crate::sse::SseParser; use axum::Router; @@ -24,14 +23,6 @@ const UPSTREAM: &str = "https://api.anthropic.com"; pub const AGENT_ID_HEADER: &str = "x-claude-code-agent-id"; pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id"; -/// Claude Code asks for the **1M-context window** with a beta flag, not a -/// different model: `--model opus[1m]` sends `anthropic-beta: …,context-1m-…` -/// while plain `opus` does not, and both report the same `model` in the body -/// (and in the transcript). So this header is the only place the window is -/// observable — `App::resume_model` needs it to resume a session the way it ran. -pub const BETA_HEADER: &str = "anthropic-beta"; -pub const LONG_CONTEXT_BETA: &str = "context-1m"; - /// Most of a failed response we keep in order to name the error. An Anthropic /// error body is a few hundred bytes; the cap exists so a pathological upstream /// can't make the tee task grow without bound (the relay itself is unaffected @@ -206,16 +197,6 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { label_server_tool_lane(&ctx.app, &key, t.lane(), &v); } record_user_prompt(&ctx.app, &key, t.lane(), &v); - // Context window of the *main chain*, from this request's betas. Only a - // real turn-starting main-chain request counts: a side/title call runs - // haiku without the flag, a subagent runs its own model, and a nested - // server-tool call never carries the flag at all — reading any of them - // would report a window that is not the session's (the server-tool case - // silently downgraded a `[1m]` session to the short window on resume). - if kind == ReqKind::Turn && t.lane() == crate::app::MAIN_LANE { - let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA)); - record_long_context(&ctx.app, &key, long); - } tap = Some(t); } diff --git a/src/term.rs b/src/term.rs index 2ce958f..0b7d603 100644 --- a/src/term.rs +++ b/src/term.rs @@ -969,10 +969,10 @@ fn conv_color(c: ColorAttribute) -> Option { } } -/// Claude Code's own default model, as a `--model` argument (`opus`, -/// `opus[1m]`, …). Its settings files are the one place the **1M-context** -/// choice is written down — `/model` saves the pick there, suffix and all, -/// while a transcript records the same base model id either way. +/// Claude Code's own default model, as it writes it (`opus`, `opus[1m]`, …). +/// Only the *model* is used: `App::spawn_arg` / `App::resume_arg` strip any +/// suffix and re-apply the 1M window themselves, so a `default` pick or a +/// session we know nothing about still names a model we can attach `[1m]` to. /// /// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then /// project-local, project, and user settings. `None` when nothing sets one @@ -1006,21 +1006,21 @@ fn settings_model(path: &std::path::Path) -> Option { .map(str::to_string) } -/// Background scan that replaces `App::model_choices` with the live alias set -/// read from the installed `claude` binary (see `discover_model_aliases`). -/// Runs off the UI thread; on failure the seeded fallback list stays in place. +/// Background scan that replaces `App::models` with the live sets read from the +/// installed `claude` binary (see `discover_models`). Runs off the UI thread; +/// on failure the seeded fallback catalog stays in place. pub fn spawn_model_discovery(app: crate::app::SharedApp) { std::thread::spawn(move || { - if let Some(choices) = discover_model_choices() { - crate::app::lock_app(&app).model_choices = choices; + if let Some(models) = discover_models() { + crate::app::lock_app(&app).models = models; } }); } /// Best-effort discovery of the models the installed `claude` accepts (aliases -/// like `opus`, `sonnet`, `haiku`, `fable`, plus their `[1m]` -/// long-context variants), so the `a` picker tracks new models without us -/// hardcoding a list that drifts. +/// like `opus`, `sonnet`, `haiku`, `fable`, and which of them ship a +/// `[1m]` long-context variant), so the `a` picker and the always-1M +/// policy track new models without us hardcoding a list that drifts. /// /// Claude Code ships as one self-contained executable with its (minified) JS /// bundle embedded; the alias set appears verbatim as a JSON array literal like @@ -1028,28 +1028,25 @@ pub fn spawn_model_discovery(app: crate::app::SharedApp) { /// quoted `"sonnet[1m]"` literal. We resolve the `claude` binary on PATH and /// read it once. This issues **no API request** (the project's core constraint) /// and never executes claude. Returns None if the binary can't be found/read or -/// nothing matches — the caller keeps its built-in fallback list. -fn discover_model_choices() -> Option> { +/// nothing matches — the caller keeps its built-in fallback catalog. +fn discover_models() -> Option { let bytes = std::fs::read(claude_binary_path()?).ok()?; let aliases = longest_alias_array(&bytes)?; - Some(model_choices_from(&bytes, &aliases)) + Some(models_from(&bytes, aliases)) } -/// Assemble picker entries `(label, --model arg)`: `default` (no `--model` -/// flag) first, then every alias, then the `[1m]` long-context variants -/// the binary actually ships (see `long_context_tokens`). -fn model_choices_from(bytes: &[u8], aliases: &[String]) -> Vec<(String, String)> { - let long = long_context_tokens(bytes); - let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())]; - choices.extend(aliases.iter().map(|a| (a.clone(), a.clone()))); - choices.extend( - aliases - .iter() - .map(|a| format!("{a}[1m]")) - .filter(|v| long.contains(v)) - .map(|v| (format!("{v} (1M context)"), v)), - ); - choices +/// Build the catalog: every alias, plus every model the binary really ships a +/// `[1m]` variant for — never assumed. `long` keeps the *base* name of each +/// `"[1m]"` literal (see `long_context_tokens`), so it covers full model +/// ids as well as aliases: a session whose transcript names a model with no +/// alias still resumes at the long window. +fn models_from(bytes: &[u8], aliases: Vec) -> crate::app::Models { + let mut long: Vec = long_context_tokens(bytes) + .iter() + .map(|t| crate::app::base_model(t).to_string()) + .collect(); + long.sort(); + crate::app::Models { aliases, long } } /// Resolve `claude` on `PATH` to a readable file path (symlinks followed). @@ -1181,14 +1178,20 @@ mod tests { } #[test] - fn appends_1m_choices_for_aliases_that_have_them() { - let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]""#; + fn catalog_marks_only_aliases_with_a_real_1m_variant() { + let bytes = + br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]" "claude-mythos-1[1m]""#; let aliases = longest_alias_array(bytes).unwrap(); - let got = model_choices_from(bytes, &aliases); - let args: Vec<&str> = got.iter().map(|c| c.1.as_str()).collect(); - // default (no flag), the plain aliases, then only the real 1M variants. - assert_eq!(args, ["", "sonnet", "opus", "haiku", "sonnet[1m]", "opus[1m]"]); - assert_eq!(got[4].0, "sonnet[1m] (1M context)"); + let models = models_from(bytes, aliases); + assert_eq!(models.aliases, ["sonnet", "opus", "haiku"]); + // Full ids count too: a transcript can name a model no alias covers. + assert_eq!(models.long, ["claude-mythos-1", "opus", "sonnet"]); + assert_eq!(models.arg("claude-mythos-1"), "claude-mythos-1[1m]"); + assert_eq!(models.arg("haiku"), "haiku"); + // Picker rows: every model at the window `Models::arg` gives it — + // 1M where the binary ships one, plain where it does not. + let args: Vec = models.choices().into_iter().map(|c| c.1).collect(); + assert_eq!(args, ["", "sonnet[1m]", "opus[1m]", "haiku"]); } /// Build a `rows` fixture (visible text per screen row) from string slices. diff --git a/src/ui.rs b/src/ui.rs index 70aa1cd..d87722f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -690,9 +690,11 @@ fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { kill_current_embed(eui, app); } if eui.term.is_none() { - // Real dimensions are applied on the first draw via resize(). - match EmbeddedTerm::spawn(eui.port, 20, 80, "") { - Ok(t) => bind_fresh_pane(eui, app, t, ""), + // Real dimensions are applied on the first draw via resize(): the + // model is Claude Code's own default, at the 1M window (`spawn_arg`). + let model = app.lock().unwrap().spawn_arg(""); + match EmbeddedTerm::spawn(eui.port, 20, 80, &model) { + Ok(t) => bind_fresh_pane(eui, app, t, &model), Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); return; @@ -714,10 +716,14 @@ fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { /// (optionally `--model `), killing any current pane first. Unlike /// `show_embed_pane` this never reuses an existing child — the point is to /// start a brand-new session without resume + /clear. -fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) { +/// +/// `pick` is the picker row's argument; `App::spawn_arg` resolves the empty +/// `default` row and guarantees the 1M window either way. +fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, pick: &str) { kill_current_embed(eui, app); - match EmbeddedTerm::spawn(eui.port, 20, 80, model) { - Ok(t) => bind_fresh_pane(eui, app, t, model), + let model = app.lock().unwrap().spawn_arg(pick); + match EmbeddedTerm::spawn(eui.port, 20, 80, &model) { + Ok(t) => bind_fresh_pane(eui, app, t, &model), Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); return; @@ -732,7 +738,8 @@ fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) { /// only expensive path, and it only runs from an explicit ctrl-↓ / `c`. /// /// The resume carries the session's own model forward (`App::resume_model`, -/// read from its transcript) instead of falling back to the CLI default. +/// read from its transcript) instead of falling back to the CLI default, and +/// always at the 1M context window. fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) { kill_current_embed(eui, app); let model = app.lock().unwrap().resume_model(session_id); @@ -1027,14 +1034,15 @@ fn event_loop( // Model picker (opened with `a`): j/k move, Enter spawns a fresh // session with the chosen model, esc/a/q cancels. if let Some(msel) = a.model_popup { - let n = a.model_choices.len().max(1); + let choices = a.models.choices(); + let n = choices.len().max(1); match k.code { KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n), KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n), KeyCode::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None, KeyCode::Enter => { a.model_popup = None; - let model = a.model_choices.get(msel).map(|c| c.1.clone()); + let model = choices.get(msel).map(|c| c.1.clone()); drop(a); if let Some(model) = model { show_embed_new(eui, &app, &model); @@ -1711,8 +1719,9 @@ fn draw( // Model picker popup (n → choose a model → fresh session). if let Some(msel) = a.model_popup { + let choices = a.models.choices(); let w = 30u16.min(main.width); - let h = (a.model_choices.len() as u16 + 2).min(main.height); + let h = (choices.len() as u16 + 2).min(main.height); let area = Rect { x: main.x + (main.width.saturating_sub(w)) / 2, y: main.y + (main.height.saturating_sub(h)) / 2, @@ -1720,8 +1729,7 @@ fn draw( height: h, }; f.render_widget(Clear, area); - let items: Vec = a - .model_choices + let items: Vec = choices .iter() .map(|(label, _)| ListItem::new(format!(" {label}"))) .collect();