Run every model at the 1M context window
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.
This commit is contained in:
91
CLAUDE.md
91
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 `<task-notification>` treatment as the live path — notes
|
||||
lifted into Kind::TaskNote, the `<result>` moved onto the `Agent`
|
||||
@@ -145,8 +146,9 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` 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 · "<query>"`, 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: <hex>`), 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 `<synthetic>` 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 `<base>[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 <base>[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: <hex>`), 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
|
||||
`"<token>[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 `"<token>[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
|
||||
|
||||
364
src/app.rs
364
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 `<alias>[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
|
||||
/// (`<synthetic>`) and our `(resumed)` filler yield None.
|
||||
pub fn model_arg_for_id(id: &str, aliases: &[String]) -> Option<String> {
|
||||
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 `<base>[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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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 (`<synthetic>`) and our
|
||||
/// `(resumed)` filler yield None.
|
||||
pub fn base_for_id(&self, id: &str) -> Option<String> {
|
||||
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<Session>,
|
||||
pub selected: usize,
|
||||
@@ -84,12 +121,12 @@ pub struct App {
|
||||
/// `Some(selected_row)` while the filter popup is open.
|
||||
pub filter_popup: Option<usize>,
|
||||
/// `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<usize>,
|
||||
/// 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<String> =
|
||||
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<String>,
|
||||
/// Whether the main chain runs with the **1M-context window**, observed on
|
||||
/// the wire (`anthropic-beta: …context-1m…`, what a `<alias>[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<bool>,
|
||||
}
|
||||
|
||||
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 `_<date>` 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<String> {
|
||||
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("<synthetic>"), None);
|
||||
assert_eq!(arg("(resumed)"), None);
|
||||
assert_eq!(arg(""), None);
|
||||
assert_eq!(base("<synthetic>"), 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));
|
||||
|
||||
21
src/proxy.rs
21
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<Response> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
77
src/term.rs
77
src/term.rs
@@ -969,10 +969,10 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
.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 `<alias>[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
|
||||
/// `<alias>[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<Vec<(String, String)>> {
|
||||
/// nothing matches — the caller keeps its built-in fallback catalog.
|
||||
fn discover_models() -> Option<crate::app::Models> {
|
||||
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 `<alias>[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
|
||||
/// `"<name>[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<String>) -> crate::app::Models {
|
||||
let mut long: Vec<String> = 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<String> = 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.
|
||||
|
||||
32
src/ui.rs
32
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 <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<ListItem> = a
|
||||
.model_choices
|
||||
let items: Vec<ListItem> = choices
|
||||
.iter()
|
||||
.map(|(label, _)| ListItem::new(format!(" {label}")))
|
||||
.collect();
|
||||
|
||||
Reference in New Issue
Block a user