auto resume

This commit is contained in:
Jonas H
2026-08-17 07:47:47 +02:00
parent fc9c05ce97
commit a7d7940be3
5 changed files with 805 additions and 115 deletions

View File

@@ -68,8 +68,10 @@ src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing,
width-fitted wrapped columns) and strips heading `#` markers —
the pinned tui-markdown 0.3.5 does neither
src/sessions.rs on-disk session history: background scanner thread keeps
App::disk_sessions fresh (~1/s poll, labels re-read only on
mtime change); load_view/load_history rebuild a feed Session
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
from a JSONL transcript (lazily, on first view); build_tree
parses uuid/parentUuid chains into a TurnTree (one node per
real user prompt; rewinds leave fork points); materialize
@@ -140,7 +142,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
transcript — but a second ctrl-↓ within 3s forces it (liveness is
unknowable: an idle claude sends no traffic; `EmbedUi::past_embeds` skips
the guard for sessions whose instance we killed ourselves). `--session-id`
cannot be combined with `--resume` (CLI rejects it without `--fork-session`).
cannot be combined with `--resume` (CLI rejects it without `--fork-session`);
`--model` can, and every resume passes it.
- **A resume continues on the session's own model**, not the CLI default:
`App::resume_model` reads the model Claude Code recorded for the session's
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
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. `[1m]`
(1M-context) picks are the one thing it cannot see — the transcript records
the same base id either way — so `Session::spawn_model` remembers the exact
argument the pane was spawned with in-process and wins **only** while it
still names the model the transcript reports.
- **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
@@ -189,14 +206,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
below the bottom rule for a menu row, not just the row directly under it: the
list can start after a blank/header row and only the highlighted item carries
a glyph (unselected file rows are plain names), so checking one row collapsed
the pane whenever that row wasn't the selected item. The framed region drives the pane height too:
the pane whenever that row wasn't the selected item. Above the top rule the
frame also swallows an **active task panel** (`text_is_task_row` /
`task_block_top`): Claude Code parks the `N tasks (…)` header + `✔ ◼ ◻` rows
(and its `… +N pending` overflow line) directly above the input box, so
walking up over that block — tolerating one blank line and single wrapped /
activity rows, capped at `MAX_TASK_BLOCK` — makes task status visible with no
extra app state. Priority when the pane can't hold everything: the panel is
dropped first (`CompactFrame::ess_top`, the one-context-row frame) so the
line you're typing and an open menu never fall off screen. The framed region drives the pane height too:
`compact_rows` (called from `ui::draw`) measures box-height + tail so the
pane auto-expands as the prompt gains lines or a menu opens and shrinks back
when idle (floor `MIN_COMPACT_INNER`, cap = screen 6); `PTY_PAD` keeps the
PTY taller than the visible window so the child can still draw the rows we
crop. **The Compact PTY is sized to the *whole screen height*, not the
visible pane** (`ui::draw` passes `f.area().height` to `resize` only for
Compact): Compact's height is derived by measuring what Ink has already
crop. **A cropped PTY is sized to the *whole screen height*, not the
visible pane** (`ui::draw` passes `f.area().height` to `resize` for every
view except Full): their height is derived by measuring what Ink has already
drawn, and Ink only ever draws as many rows as the PTY reports, so tying the
PTY to the (small) visible height is a feedback loop — an `@`/`/` menu or a
big paste that suddenly needs many more rows than the current PTY+pad never
@@ -212,9 +237,20 @@ UI thread redraws on its own tick (no channel; just the mutex).
returns `None` on a transient mid-repaint (box border caught missing) so the
last height is kept. Without this the height oscillates every frame during a
subagent turn or `@`/`/` menu filtering, and each change resizes the PTY →
Ink repaints → flicker. `PaneView::Interactive` (the tap-grown AskUserQuestion / ExitPlanMode
pane, whose selection box renders *above* the input) top-anchors from row 2
instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders
Ink repaints → flicker. `PaneView::Interactive` (the tap-grown
AskUserQuestion / ExitPlanMode pane, whose selection box renders *above* the
input) is **measured the same way, never estimated**: `interactive_frame`
anchors on the rule above the header-chip row (`← ☐ Header ✔ Submit →`),
else the second-to-last rule, and runs to the last non-blank row;
`EmbeddedTerm::interactive_rows` feeds that height through the same
hysteresis. `App::ask_question_rows` (the row guess from the tool JSON) is
only the fallback for the frames before Ink has drawn the box — it can't know
how far the question text wraps, which is what used to crop the first
paragraph. Because the Interactive PTY is now screen-tall, Claude Code lays
the prompt out in full instead of switching to its own truncated form.
`interactive_view_range` top-anchors on that frame and slides down only far
enough to keep the `` option on screen when the prompt overflows the pane.
`PaneView::Full` (fullscreen) renders
the child's screen verbatim from row 0 with the PTY sized exactly to the
pane. Permission-prompt boxes (rounded borders, not rules, and not in the
API stream) aren't expanded in the compact pane — consistent with the
@@ -235,7 +271,14 @@ UI thread redraws on its own tick (no channel; just the mutex).
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`. Tab/BackTab cycle sessions (`p` no longer
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.
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
(`App::prompt_jump`, applied in `draw` where entry heights are cached). The
@@ -275,7 +318,15 @@ UI thread redraws on its own tick (no channel; just the mutex).
`127.0.0.1:<port>/v1/messages` without auth — a relayed 401 from Anthropic
proves the round-trip. The TUI can't run in a non-tty. `CT_UPSTREAM` points
the proxy at an alternative upstream (e.g. a local fake SSE server) for
fully offline end-to-end tests with zero API usage.
fully offline end-to-end tests with zero API usage. That fake server is
`dev/fake_upstream.py`: it answers every request with canned SSE, so a **real
`claude` child** can be made to render its client-side tool UIs on demand
(`dev/.fake_scenario` = `ask | plan | todo | taskupdate | text`, switchable
mid-run) — this is how the pane's frame detector is developed against what
Ink actually draws. Drive it through tmux (`.claude/skills/tui-verify`) and
obey that skill's safety rule: **never `pkill`/`killall`**, tear down only
your own named tmux session. The child writes real task files under
`~/.claude/tasks/<its-session-id>/`; delete that directory afterwards.
## Not yet handled (known MVP limits)

View File

@@ -20,6 +20,11 @@ pub const FILTER_LABELS: [&str; 7] =
/// 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", ""),
@@ -27,12 +32,39 @@ pub fn default_model_choices() -> Vec<(String, String)> {
("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))
}
pub struct App {
pub sessions: Vec<Session>,
pub selected: usize,
@@ -197,6 +229,44 @@ impl App {
true
}
/// The `--model` argument a resume of `key` should spawn with, so the pane
/// continues on the model that session last used instead of the CLI
/// default. Empty string = pass no `--model` flag.
///
/// The on-disk transcript is the source of truth: Claude Code records the
/// model of every assistant message, so a mid-session `/model` switch is
/// visible there. The pane's own spawn argument is only consulted to keep a
/// `[1m]` (1M-context) pick alive — the transcript records the base model id
/// for both — and only while it still names the same model.
pub fn resume_model(&self, key: &str) -> String {
let aliases: Vec<String> =
self.model_choices.iter().map(|(_, a)| a.clone()).collect();
let disk = self
.disk_sessions
.iter()
.find(|d| d.uuid == key)
.and_then(|d| model_arg_for_id(&d.model, &aliases));
let spawned = self
.sessions
.iter()
.find(|s| s.key == key)
.and_then(|s| s.spawn_model.clone())
.filter(|m| !m.is_empty());
match (disk, spawned) {
(Some(d), Some(s)) if s.split('[').next() == Some(d.as_str()) => s,
(Some(d), _) => d,
(None, s) => s.unwrap_or_default(),
}
}
/// Remember the `--model` argument the pane for `key` was spawned with
/// (see `resume_model`). No-op when the session row doesn't exist yet.
pub fn set_spawn_model(&mut self, key: &str, model: &str) {
if let Some(s) = self.sessions.iter_mut().find(|s| s.key == key) {
s.spawn_model = Some(model.to_string());
}
}
/// Indices into `disk_sessions` that should appear as stubs: everything
/// not already present as a live session (a live session *is* on disk —
/// Claude Code writes the JSONL continuously — so dedupe by uuid).
@@ -412,7 +482,16 @@ impl App {
};
idxs.dedup();
let title = format!("{}", e.tree.turns[*idxs.last().unwrap()].label);
let src_uuid = e.uuid.clone();
let new_uuid = crate::sessions::materialize(&e.tree, &idxs, &title)?;
// The branch file carries the source session's assistant records, so it
// resumes on the same model. The next scan re-reads it from disk; this
// is only the value for the ~1s until then.
let model = self
.disk_sessions
.iter()
.find(|d| d.uuid == src_uuid)
.map_or(String::new(), |d| d.model.clone());
if let Some(e) = self.expanded.as_mut() {
e.visual = None;
e.sel = None; // the selection moves to the new stub
@@ -424,6 +503,7 @@ impl App {
crate::sessions::DiskSession {
uuid: new_uuid.clone(),
label: title,
model,
modified: std::time::SystemTime::now(),
},
);
@@ -478,6 +558,11 @@ pub struct Session {
/// Signature (joined tool names) of the tool set last surfaced as a
/// `Kind::ToolDefs` entry; re-emitted only when the available tools change.
pub last_tools_sig: Option<String>,
/// 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.
pub spawn_model: Option<String>,
}
impl Session {
@@ -493,6 +578,7 @@ impl Session {
tool_ids: HashMap::new(),
last_system_len: None,
last_tools_sig: None,
spawn_model: None,
}
}
}
@@ -1072,6 +1158,65 @@ mod tests {
use super::*;
use serde_json::json;
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 {
uuid: uuid.into(),
label: uuid.into(),
model: model.into(),
modified: std::time::SystemTime::now(),
}
}
#[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"));
// 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"));
// Placeholders yield nothing: no `--model` flag at all.
assert_eq!(arg("<synthetic>"), None);
assert_eq!(arg("(resumed)"), None);
assert_eq!(arg(""), None);
}
#[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_model("s1"), "opus");
// Unknown session → no flag.
assert_eq!(a.resume_model("nope"), "");
}
#[test]
fn resume_model_keeps_a_1m_pick_until_the_model_changes() {
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).
a.disk_sessions = vec![stub("s1", "claude-sonnet-4-5-20250929")];
assert_eq!(a.resume_model("s1"), "sonnet[1m]");
// 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_model("s1"), "opus");
// No transcript model yet (spawned, never answered) → the spawn arg.
a.disk_sessions.clear();
assert_eq!(a.resume_model("s1"), "sonnet[1m]");
// The CLI default is remembered as "no flag", not as a model.
a.set_spawn_model("s1", "");
assert_eq!(a.resume_model("s1"), "");
}
#[test]
fn tool_result_attaches_to_entry() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));
@@ -1497,11 +1642,7 @@ mod tests {
}
fn ds(uuid: &str) -> crate::sessions::DiskSession {
crate::sessions::DiskSession {
uuid: uuid.into(),
label: uuid.into(),
modified: std::time::SystemTime::now(),
}
stub(uuid, "")
}
#[test]

View File

@@ -24,21 +24,26 @@ pub struct DiskSession {
pub uuid: String,
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
pub label: String,
/// API model id of the session's last main-chain assistant message
/// (empty when the file has none yet). `App::resume_model` turns it into
/// the `--model` argument a resume spawns with.
pub model: String,
pub modified: SystemTime,
}
/// Background scanner: keeps `App::disk_sessions` in sync with the project
/// directory so the UI never does disk I/O for the session list. Polls ~1/s;
/// labels are re-read only for files whose mtime changed, so the steady-state
/// cost is one `read_dir` + a stat per file. The app mutex is only taken when
/// the list actually changed.
/// labels and models are re-read only for files whose mtime changed, so the
/// steady-state cost is one `read_dir` + a stat per file. The app mutex is
/// only taken when the list actually changed.
pub fn spawn_scanner(app: SharedApp) {
std::thread::spawn(move || {
// uuid → (mtime when read, label): skip re-parsing unchanged files.
let mut labels: HashMap<String, (SystemTime, String)> = HashMap::new();
// uuid → (mtime when read, label, model): skip re-parsing unchanged
// files (one pass yields both — see `read_meta`).
let mut meta: HashMap<String, (SystemTime, String, String)> = HashMap::new();
let mut last: Vec<DiskSession> = Vec::new();
loop {
let list = scan(&mut labels).unwrap_or_default();
let list = scan(&mut meta).unwrap_or_default();
if list != last {
last = list.clone();
lock_app(&app).set_disk_sessions(list);
@@ -50,7 +55,7 @@ pub fn spawn_scanner(app: SharedApp) {
/// One scan of the project directory, newest first.
fn scan(
labels: &mut HashMap<String, (SystemTime, String)>,
meta: &mut HashMap<String, (SystemTime, String, String)>,
) -> Result<Vec<DiskSession>, String> {
let dir = project_dir()?;
let rd = std::fs::read_dir(&dir)
@@ -64,15 +69,15 @@ fn scan(
}
let uuid = path.file_stem()?.to_str()?.to_string();
let modified = e.metadata().ok()?.modified().ok()?;
let label = match labels.get(&uuid) {
Some((m, l)) if *m == modified => l.clone(),
let (label, model) = match meta.get(&uuid) {
Some((m, l, md)) if *m == modified => (l.clone(), md.clone()),
_ => {
let l = read_label(&path, &uuid);
labels.insert(uuid.clone(), (modified, l.clone()));
l
let read = read_meta(&path, &uuid);
meta.insert(uuid.clone(), (modified, read.0.clone(), read.1.clone()));
read
}
};
Some(DiskSession { uuid, label, modified })
Some(DiskSession { uuid, label, model, modified })
})
.collect();
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
@@ -408,6 +413,7 @@ impl EntryParser {
tool_ids: HashMap::new(),
last_system_len: None,
last_tools_sig: None,
spawn_model: None,
},
leaf,
turn_entries,
@@ -562,25 +568,37 @@ fn encode_cwd(path: &str) -> String {
.collect()
}
/// Read a human-readable label from the JSONL file. Prefers `ai-title`;
/// falls back to the first non-empty `last-prompt` text; then uuid prefix.
fn read_label(path: &std::path::Path, uuid: &str) -> String {
/// Read a session's list metadata from its JSONL file in one pass:
/// `(label, model)`.
///
/// The label prefers `ai-title`; it falls back to the last non-empty
/// `last-prompt` text, then the uuid prefix. The model is the API model id of
/// the last *main-chain* assistant message — what Claude Code itself recorded
/// for the newest turn, so a resume can continue on it. Subagent records
/// (`isSidechain`) carry their own model and are skipped, as are the
/// `<synthetic>` ids Claude Code writes for API-error records.
fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
let fallback = || uuid.chars().take(8).collect::<String>();
let Ok(f) = std::fs::File::open(path) else {
return fallback();
return (fallback(), String::new());
};
let reader = std::io::BufReader::new(f);
let mut ai_title: Option<String> = None;
let mut last_prompt: Option<String> = None;
let mut model = String::new();
for line in reader.lines().map_while(Result::ok) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
match v.get("type").and_then(|t| t.as_str()) {
Some("ai-title") => {
// First title wins (the scan no longer stops there — it still
// needs the model): a branch file we materialized carries our own
// `⑂ …` title first, and Claude Code may append its own later.
Some("ai-title") if ai_title.is_none() => {
if let Some(t) = v.get("aiTitle").and_then(|t| t.as_str())
&& !t.is_empty()
{
return one_line(t);
ai_title = Some(one_line(t));
}
}
Some("last-prompt") => {
@@ -590,10 +608,19 @@ fn read_label(path: &std::path::Path, uuid: &str) -> String {
last_prompt = Some(one_line(p));
}
}
Some("assistant") => {
if v.get("isSidechain").and_then(Value::as_bool) != Some(true)
&& let Some(m) = v.pointer("/message/model").and_then(Value::as_str)
&& !m.starts_with('<')
{
model = m.to_string();
}
}
_ => {}
}
}
last_prompt.unwrap_or_else(fallback)
let label = ai_title.or(last_prompt).unwrap_or_else(fallback);
(label, model)
}
/// First line only, control characters dropped — labels go into a one-row
@@ -787,8 +814,9 @@ mod tests {
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
r#"{"type":"ai-title","aiTitle":"retitled later"}"#,
]);
assert_eq!(read_label(&p, "deadbeef-0000"), "bug fixing session");
assert_eq!(read_meta(&p, "deadbeef-0000").0, "bug fixing session");
std::fs::remove_file(p).ok();
}
@@ -798,11 +826,30 @@ mod tests {
r#"{"type":"last-prompt","lastPrompt":"first"}"#,
r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#,
]);
assert_eq!(read_label(&p, "deadbeef-0000"), "latest");
assert_eq!(read_meta(&p, "deadbeef-0000").0, "latest");
std::fs::remove_file(p).ok();
let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]);
assert_eq!(read_label(&p, "deadbeef-0000"), "deadbeef");
assert_eq!(read_meta(&p, "deadbeef-0000").0, "deadbeef");
std::fs::remove_file(p).ok();
}
#[test]
fn meta_reads_the_last_main_chain_model() {
let p = write_jsonl(&[
r#"{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","content":[]}}"#,
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-5-20250929","content":[]}}"#,
// A subagent turn runs its own model — not the session's.
r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-haiku-4-5-20251001","content":[]}}"#,
// An API-error record carries a placeholder id, not a model.
r#"{"type":"assistant","message":{"model":"<synthetic>","content":[]}}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "claude-sonnet-4-5-20250929");
std::fs::remove_file(p).ok();
// A session with no assistant record yet reports no model.
let p = write_jsonl(&[r#"{"type":"user","message":{"role":"user","content":"hi"}}"#]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "");
std::fs::remove_file(p).ok();
}

View File

@@ -115,11 +115,22 @@ impl EmbeddedTerm {
/// but Claude Code is not guaranteed to (it can mint a fresh id on resume),
/// so the tap still correlates by the injected pane token and *rebinds* the
/// embed to whatever id the traffic actually reports. `--session-id` must
/// NOT be passed alongside `--resume` (rejected without `--fork-session`).
pub fn spawn_resume(port: u16, rows: u16, cols: u16, session_id: &str) -> anyhow::Result<Self> {
/// NOT be passed alongside `--resume` (rejected without `--fork-session`);
/// `--model` may be, and carries the session's last model forward
/// (`App::resume_model`) so a resume doesn't drop back to the CLI default.
pub fn spawn_resume(
port: u16,
rows: u16,
cols: u16,
session_id: &str,
model: &str,
) -> anyhow::Result<Self> {
let pane_token = uuid::Uuid::new_v4().to_string();
let mut cmd = CommandBuilder::new("claude");
cmd.args(["--resume", session_id]);
if !model.is_empty() {
cmd.args(["--model", model]);
}
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}"));
cmd.env("ANTHROPIC_CUSTOM_HEADERS", format!("{PANE_TOKEN_HEADER}: {pane_token}"));
if let Ok(cwd) = std::env::current_dir() {
@@ -292,13 +303,29 @@ impl EmbeddedTerm {
/// known height on `None` rather than snapping to a default, which is what
/// stops the pane from flickering during busy output.
pub fn compact_rows(&self) -> Option<u16> {
let (top, bottom) = compact_frame(&self.screen_rows())?;
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
}
/// Inner rows the pane wants for the interactive prompt Claude Code draws
/// for AskUserQuestion / ExitPlanMode (see `interactive_frame`). Measured
/// from the child's screen for the same reason `compact_rows` is: the tap's
/// row estimate from the tool JSON can only guess how far the question text
/// and option descriptions wrap, and guessing short is what cropped the top
/// of the prompt. `None` until the prompt has been drawn — the caller then
/// keeps its previous height (or the tap's estimate as a first guess).
pub fn interactive_rows(&self) -> Option<u16> {
let (top, bottom) = interactive_frame(&self.screen_rows())?;
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
}
/// Visible text of every screen row, top to bottom.
fn screen_rows(&self) -> Vec<String> {
let term = self.term.lock().unwrap();
let screen = term.screen();
let first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
let rows: Vec<String> = lines.iter().map(row_text).collect();
let (top, bottom) = compact_frame(&rows)?;
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
lines.iter().map(row_text).collect()
}
/// Paint a window of the child's screen into `area`. Returns the cursor
@@ -310,9 +337,9 @@ impl EmbeddedTerm {
/// statusLine, cropping the persistent hint/token/effort chrome below it;
/// shows a whole `@`/`/` menu instead when one is open. Bottom-anchored
/// if the pane is shorter than the framed region.
/// - `Interactive`: the same pane grown by the tap for an
/// AskUserQuestion / ExitPlanMode prompt, whose selection box renders
/// *above* the input — top-anchored (from row 2) so it stays visible.
/// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
/// the pane for — framed from its own top border (see
/// `interactive_view_range`) so the question text is never cropped.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off.
pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
let term = self.term.lock().unwrap();
@@ -328,13 +355,7 @@ impl EmbeddedTerm {
// not have landed yet — clamp to whatever fits.
(0, lines.len().min(h).saturating_sub(1))
}
PaneView::Interactive => {
// The prompt box renders above the input; keep showing from
// near the top so it's visible, bottom-anchored if it overflows.
let end = last;
let start = (end + 1).saturating_sub(h).max(2).min(end);
(start, end)
}
PaneView::Interactive => interactive_view_range(&rows, last, h),
PaneView::Compact => compact_view_range(&rows, last, h),
};
for (y, line) in lines[start..=end].iter().enumerate() {
@@ -410,6 +431,80 @@ fn text_is_menu_item(t: &str) -> bool {
["+ ", "* ", " ", " "].iter().any(|m| t.starts_with(m)) || t.starts_with('/')
}
/// Status glyphs Claude Code prints in front of a task/todo row: pending
/// (`squareSmall`), in-progress (`squareSmallFilled`) and completed (`tick`).
/// The same `☐`/`◻` glyph also leads an AskUserQuestion header chip, which is
/// what `interactive_frame` keys on.
const TASK_GLYPHS: [char; 6] = ['◻', '◼', '✔', '✓', '☐', '☒'];
/// A row of Claude Code's task/todo panel — the block it keeps directly above
/// the input box while a task list is alive. Two shapes exist:
/// - standalone (turn finished): a `N tasks (x done, y open)` header followed
/// by up to 5 `◻/◼/✔ subject` rows and a dim `… +3 pending` overflow row;
/// - in-flight (turn running): the same rows hung under the spinner row with a
/// `⎿` tool-result gutter.
///
/// `⎿` alone is *every* tool result's gutter, so it only counts here when a
/// task glyph follows it — otherwise a plain `⎿ Read 20 lines` would grow the
/// pane on every tool call.
fn text_is_task_row(t: &str) -> bool {
let t = t.trim_start();
let after_gutter = t.strip_prefix('⎿').map(str::trim_start).unwrap_or(t);
if after_gutter.starts_with(TASK_GLYPHS) {
return true;
}
// Dim overflow tail: "… +3 pending, 2 completed".
if t.starts_with('…') {
return true;
}
// Standalone header: "268 tasks (0 done, 268 open)".
let digits: String = t.chars().take_while(char::is_ascii_digit).collect();
!digits.is_empty() && t[digits.len()..].starts_with(" tasks (")
}
/// Walk up from `from` (the row just above the input box) over Claude Code's
/// task panel and return its topmost row, or None when no task block sits
/// there. One blank row is tolerated on the way in (the panel is drawn with a
/// `marginTop`), and the walk is bounded so a screen full of glyph-ish text
/// can't swallow the whole pane.
fn task_block_top(rows: &[String], from: usize) -> Option<usize> {
/// Panel worst case: header + 5 task rows + 5 activity rows + overflow.
const MAX_TASK_BLOCK: usize = 14;
let mut i = from;
if rows[i].trim().is_empty() {
i = i.checked_sub(1)?;
}
if !text_is_task_row(&rows[i]) {
return None;
}
let floor = i.saturating_sub(MAX_TASK_BLOCK);
while i > floor {
if text_is_task_row(&rows[i - 1]) {
i -= 1;
continue;
}
// An in-progress task can carry a dim activity line under it, and a
// long subject wraps — neither starts with a glyph. Step over a single
// such row when a real task row sits above it.
if i >= floor + 2 && !rows[i - 1].trim().is_empty() && text_is_task_row(&rows[i - 2]) {
i -= 2;
continue;
}
break;
}
Some(i)
}
/// Step one row further up when `i` lands on a blank row, so the frame's top
/// context row carries text (the panel is drawn with a blank `marginTop` row
/// above it, and showing that blank instead of the spinner row wastes a line).
fn skip_blank_up(rows: &[String], i: usize) -> usize {
match i.checked_sub(1) {
Some(prev) if rows[i].trim().is_empty() && !rows[prev].trim().is_empty() => prev,
_ => i,
}
}
/// Locate Claude Code's input box in `rows` (the visible text of each screen
/// row) and return the inclusive range `(top, bottom)` the compact pane should
/// show: one context row above the box (the spinner / "✻ Worked…" row when
@@ -425,13 +520,26 @@ fn text_is_menu_item(t: &str) -> bool {
/// many lines tall (a long or pasted prompt), which is exactly the auto-expand
/// we want.
fn compact_frame(rows: &[String]) -> Option<(usize, usize)> {
compact_frame_ex(rows).map(|(top, bottom, _)| (top, bottom))
compact_frame_ex(rows).map(|f| (f.top, f.bottom))
}
/// Same as `compact_frame`, but also reports whether the frame ends on an open
/// `@`/`/` menu (as opposed to the idle statusLine) — `render` needs this to
/// pick which end of the region to sacrifice when it doesn't fit the pane.
fn compact_frame_ex(rows: &[String]) -> Option<(usize, usize, bool)> {
/// Where the compact pane's window sits, plus what `compact_view_range` needs
/// to decide which end to sacrifice when the region is taller than the pane.
struct CompactFrame {
/// First row to show: the task panel's top when one is up, else the single
/// context row above the input box.
top: usize,
/// The context row above the input box — the top the pane falls back to
/// when the full region doesn't fit. The task panel is a nice-to-have;
/// the input box is not.
ess_top: usize,
bottom: usize,
/// The region ends on an open `@`/`/` menu rather than the statusLine.
menu_open: bool,
}
/// Same as `compact_frame`, but keeps the fields `compact_view_range` needs.
fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
if rules.len() < 2 {
@@ -439,7 +547,16 @@ fn compact_frame_ex(rows: &[String]) -> Option<(usize, usize, bool)> {
}
let bot_div = rules[rules.len() - 1];
let top_div = rules[rules.len() - 2];
let view_top = top_div.saturating_sub(1);
// Normally one context row above the box (the spinner / "✻ Worked…" row).
// While a task list is alive Claude Code parks its task panel exactly
// there, so the frame swallows the whole panel plus the context row above
// it — that panel *is* the status of the run, and the compact pane is the
// only place the user sees it (the feed shows the API stream, not CC's UI).
let ctx_top = top_div.saturating_sub(1);
let view_top = match top_div.checked_sub(1).and_then(|i| task_block_top(rows, i)) {
Some(t) => skip_blank_up(rows, t.saturating_sub(1)),
None => ctx_top,
};
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
// list. Scan the *whole* region under the rule for a menu row, not just the
// one immediately below it: the list can start after a blank separator or a
@@ -450,30 +567,117 @@ fn compact_frame_ex(rows: &[String]) -> Option<(usize, usize, bool)> {
// match `text_is_menu_item`, so scanning stays free of false positives.
let menu_open = last > bot_div && (bot_div + 1..=last).any(|i| text_is_menu_item(&rows[i]));
let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) };
Some((view_top, view_bottom, menu_open))
Some(CompactFrame { top: view_top, ess_top: ctx_top, bottom: view_bottom, menu_open })
}
/// Pick the `(start, end)` window `render` shows for `PaneView::Compact`,
/// given the pane's available inner height `h`. Delegates to
/// `compact_frame_ex` for *where* the box/menu is, and decides which end to
/// sacrifice when the framed region is taller than the pane:
/// - menu open: top-anchor. The input box (what's being typed) sits at the
/// top of the region and the match list runs to the bottom, so overflow
/// must crop the *menu's tail*, not the input box — bottom-anchoring here
/// would hide the very thing the user is typing behind a wall of filenames.
/// - no menu: bottom-anchor on the statusLine, as before, so a long pasted
/// prompt keeps its tail + cursor visible and only context rows are cropped.
/// `compact_frame_ex` for *where* the box/menu/task panel is, and decides what
/// to sacrifice when the framed region is taller than the pane:
/// - the task panel goes first. It is context about the run; the input box is
/// what the user is driving, so an overflowing region falls back to
/// `ess_top` (the single context row above the box) before cropping
/// anything else.
/// - menu open: top-anchor from there. The input box sits at the top of what
/// remains and the match list runs to the bottom, so overflow must crop the
/// *menu's tail* — bottom-anchoring would hide the line being typed behind a
/// wall of filenames.
/// - no menu: bottom-anchor on the statusLine, so a long pasted prompt keeps
/// its tail + cursor visible and only context rows are cropped.
/// - no box located yet (startup banner, or a transient mid-repaint):
/// bottom-anchor the raw content.
fn compact_view_range(rows: &[String], last: usize, h: usize) -> (usize, usize) {
match compact_frame_ex(rows) {
Some((top, bottom, true)) => (top, bottom.min(top + h.saturating_sub(1))),
Some((top, bottom, false)) => {
let start = (bottom + 1).saturating_sub(h).max(top).min(bottom);
(start, bottom)
}
None => (last.saturating_sub(h.saturating_sub(1)), last),
let Some(f) = compact_frame_ex(rows) else {
return (last.saturating_sub(h.saturating_sub(1)), last);
};
// Drop the task panel before cropping the box itself.
let top = if f.bottom + 1 - f.top > h { f.ess_top } else { f.top };
if f.menu_open {
return (top, f.bottom.min(top + h.saturating_sub(1)));
}
let start = (f.bottom + 1).saturating_sub(h).max(top).min(f.bottom);
(start, f.bottom)
}
/// Locate the interactive prompt Claude Code draws for AskUserQuestion /
/// ExitPlanMode and return the inclusive row range the pane should show:
/// one context row above the prompt's top border down to its hint row
/// ("Enter to select · ↑/↓ to navigate · Esc to cancel"), which is the last
/// non-blank row on screen.
///
/// Shape of the question prompt in CC 2.1.x — note it *replaces* the input box
/// (there is no `` box on screen while it waits), and it draws its own borders
/// with the same `────` rules:
///
/// ```text
/// ● Let me check how you want this framed. <- context row
/// ───────────────────────────────────────── <- top border
/// ☐ Framing <- header chip
/// <- (blank)
/// The question text, wrapped over as many
/// rows as it needs.
///
/// 1. Option label <- selected option
/// option description
/// 2. …
/// ───────────────────────────────────────── <- separator above the tail
/// 5. Chat about this
///
/// Enter to select · ↑/↓ to navigate · Esc to cancel
/// ```
///
/// So the top border is *not* the last rule (that one is the separator near the
/// bottom). It is found by the header chip that follows it, and only failing
/// that by rule position. Returns None when no prompt is on screen yet — the
/// tap flips the pane to `Interactive` the moment the tool call completes,
/// which is a beat *before* Claude Code has drawn anything, so the caller keeps
/// its previous height until this starts reporting.
fn interactive_frame(rows: &[String]) -> Option<(usize, usize)> {
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
// Preferred anchor: the rule immediately above the header chip row
// (`☐ Framing`), which is the prompt box's own top border.
let chip = rules
.iter()
.rev()
.find(|&&i| rows.get(i + 1).is_some_and(|t| t.trim_start().starts_with(TASK_GLYPHS)));
let top_div = match chip {
Some(&i) => i,
// No chip (ExitPlanMode, or a chip-less variant): the trailing pair of
// rules brackets the prompt body, so take the upper one.
None if rules.len() >= 2 => rules[rules.len() - 2],
None => *rules.last()?,
};
Some((skip_blank_up(rows, top_div.saturating_sub(1)), last))
}
/// Pick the `(start, end)` window `render` shows for `PaneView::Interactive`,
/// given the pane's inner height `h`.
///
/// The prompt is top-anchored: its question text is the part that explains what
/// is being asked, and cropping it (what a bottom anchor does) is exactly the
/// reported bug. When even the top-anchored window can't reach the highlighted
/// option, the window slides down just far enough to keep that option — plus
/// the hint row below it — in view, so the prompt is always operable.
fn interactive_view_range(rows: &[String], last: usize, h: usize) -> (usize, usize) {
let Some((top, bottom)) = interactive_frame(rows) else {
// Nothing framed yet: show from near the top, bottom-anchored.
let start = (last + 1).saturating_sub(h).max(2).min(last);
return (start, last);
};
let h = h.max(1);
if bottom - top < h {
return (top, bottom);
}
// Overflow: keep the selected option (` 2. …`) visible.
let sel = (top..=bottom).rev().find(|&i| rows[i].trim_start().starts_with(''));
let mut end = top + h - 1;
if let Some(sel) = sel
&& sel > end
{
end = (sel + 1).min(bottom);
}
(end + 1 - h, end)
}
impl Drop for EmbeddedTerm {
@@ -521,29 +725,45 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
std::thread::spawn(move || {
if let Some(aliases) = discover_model_aliases() {
// "default" (no --model flag) first, then the discovered aliases.
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
choices.extend(aliases.into_iter().map(|a| (a.clone(), a)));
if let Some(choices) = discover_model_choices() {
crate::app::lock_app(&app).model_choices = choices;
}
});
}
/// Best-effort discovery of the model aliases the installed `claude` accepts
/// (e.g. `opus`, `sonnet`, `haiku`, `fable`), so the `n` picker tracks new
/// models without us hardcoding a list that drifts.
/// 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.
///
/// 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
/// `["sonnet","opus","haiku","fable"]`. We resolve the `claude` binary on PATH
/// and scan its bytes for the longest such array anchored by `opus` + `sonnet`.
/// 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_aliases() -> Option<Vec<String>> {
/// `["sonnet","opus","haiku","fable"]` and each long-context variant as its own
/// 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)>> {
let bytes = std::fs::read(claude_binary_path()?).ok()?;
longest_alias_array(&bytes)
let aliases = longest_alias_array(&bytes)?;
Some(model_choices_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
}
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
@@ -577,6 +797,42 @@ fn longest_alias_array(bytes: &[u8]) -> Option<Vec<String>> {
best
}
/// Collect every quoted `"<token>[1m]"` literal in the buffer. `[1m]` is Claude
/// Code's suffix for the 1M-context variant of a model, accepted by `--model`
/// both on aliases (`sonnet[1m]`) and on full ids (`claude-opus-4-8[1m]`). Only
/// some models have one, so we take the set from the binary instead of assuming
/// every alias supports it. One pass: find each `[1m]"` and walk back over the
/// token to its opening quote.
fn long_context_tokens(bytes: &[u8]) -> std::collections::HashSet<String> {
const SUFFIX: &[u8] = b"[1m]\"";
let mut out = std::collections::HashSet::new();
let mut i = 0;
while i + SUFFIX.len() <= bytes.len() {
if &bytes[i..i + SUFFIX.len()] != SUFFIX {
i += 1;
continue;
}
let mut s = i;
while s > 0 && is_alias_byte(bytes[s - 1]) {
s -= 1;
}
// Needs a non-empty token behind an opening quote.
if s < i && s > 0 && bytes[s - 1] == b'"' {
let end = i + SUFFIX.len() - 1; // keep `[1m]`, drop the quote
if let Ok(tok) = std::str::from_utf8(&bytes[s..end]) {
out.insert(tok.to_string());
}
}
i += SUFFIX.len();
}
out
}
/// Bytes allowed inside a model alias/id token.
fn is_alias_byte(c: u8) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-'
}
/// Parse `["a","b",...]` of lowercase-`[a-z0-9-]` tokens starting at `start`
/// (which must be `[`). Returns the tokens and the index just past the closing
/// `]`, or None if the bytes there aren't exactly such an array.
@@ -597,8 +853,7 @@ fn parse_str_array(bytes: &[u8], start: usize) -> Option<(Vec<String>, usize)> {
i += 1;
let tok_start = i;
while i < n && bytes[i] != b'"' {
let c = bytes[i];
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') {
if !is_alias_byte(bytes[i]) {
return None;
}
i += 1;
@@ -629,6 +884,27 @@ mod tests {
assert_eq!(got, ["sonnet", "opus", "haiku", "fable"]);
}
#[test]
fn collects_quoted_1m_variants() {
let bytes = br#"x"sonnet[1m]"y"claude-opus-4-8[1m]"z"[1m]"q"#;
let got = long_context_tokens(bytes);
assert!(got.contains("sonnet[1m]"));
assert!(got.contains("claude-opus-4-8[1m]"));
// The bare `"[1m]"` label string carries no model name, so it is dropped.
assert_eq!(got.len(), 2);
}
#[test]
fn appends_1m_choices_for_aliases_that_have_them() {
let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[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)");
}
/// Build a `rows` fixture (visible text per screen row) from string slices.
fn rows(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
@@ -737,6 +1013,157 @@ mod tests {
assert_eq!(compact_view_range(&screen, last, 20), (1, 10));
}
/// The AskUserQuestion prompt exactly as Claude Code 2.1.229 draws it
/// (captured from a real child through a fake upstream — see
/// `dev/fake_upstream.py`). Note it replaces the input box: no `` box, and
/// the *last* rule is a separator near the bottom, not the top border.
fn ask_prompt_screen() -> Vec<String> {
rows(&[
"", // 0
" go", // 1
"", // 2
"● Let me check how you want this framed.", // 3: context row
RULE, // 4: top border
" ☐ Framing", // 5: header chip
"", // 6
"The compact pane currently crops the top of this", // 7: question
"prompt. Which framing should the pane use?", // 8
"", // 9
" 1. Measure the box", // 10: selected
" Frame from the question box's own top border.", // 11
" 2. Fixed 75% height", // 12
" Always give the pane three quarters.", // 13
" 3. Estimate from JSON", // 14
" Keep guessing the row count.", // 15
" 4. Type something.", // 16
RULE, // 17: separator
" 5. Chat about this", // 18
"", // 19
"Enter to select · ↑/↓ to navigate · Esc to cancel", // 20: hint
"", "",
])
}
#[test]
fn frames_ask_prompt_from_its_own_top_border() {
// Regression: the old code bottom-anchored on the last non-blank row,
// so a pane shorter than the prompt cropped the *question text* — the
// part that says what is being asked. The frame now starts one context
// row above the prompt's top border and runs to the hint row.
assert_eq!(interactive_frame(&ask_prompt_screen()), Some((3, 20)));
}
#[test]
fn overflowing_ask_prompt_keeps_the_selected_option() {
let screen = ask_prompt_screen();
// Tall enough: the whole prompt, top-anchored on the context row.
assert_eq!(interactive_view_range(&screen, 20, 20), (3, 20));
// Too short: still starts at the top (question first), cropping the
// tail — the selected option (row 10) is inside the window.
assert_eq!(interactive_view_range(&screen, 20, 9), (3, 11));
}
#[test]
fn ask_prompt_window_slides_to_a_far_down_selection() {
// Same prompt with the highlight on the last option: a strictly
// top-anchored window would leave the user steering a selection they
// cannot see, so the window slides down just far enough to show it.
let mut screen = ask_prompt_screen();
screen[10] = " 1. Measure the box".into();
screen[16] = " 4. Type something.".into();
let (start, end) = interactive_view_range(&screen, 20, 8);
assert!((start..=end).contains(&16), "selected option must be visible");
assert_eq!((start, end), (10, 17));
}
#[test]
fn no_prompt_on_screen_yields_none() {
// Only the idle input box: `interactive_frame` still reports the box
// (both callers only use it while the tap says a prompt is up), but a
// blank screen has nothing to frame at all.
assert_eq!(interactive_frame(&rows(&["", "", ""])), None);
}
/// The task panel Claude Code parks above the input box while a task list
/// is alive (standalone form, turn finished).
fn task_panel_screen() -> Vec<String> {
rows(&[
"● Done.", // 0
"", // 1
"✻ Crunched for 41s", // 2: context row
"", // 3: panel marginTop
" 5 tasks (1 done, 1 in progress, 3 open)", // 4: panel header
" ✔ Capture ground truth screens", // 5
" ◼ Fix interactive pane sizing", // 6
" measuring the rendered box…", // 7: activity row
" ◻ Expand pane while a task list is active", // 8
" … +2 pending", // 9: overflow tail
"", // 10
&format!("{RULE} minimal ──"), // 11: top rule
"", // 12: input
RULE, // 13: bottom rule
"Session: ▓▓░ Context | Opus", // 14: statusLine
"⏵⏵ bypass permissions", // 15: chrome (cropped)
])
}
#[test]
fn frames_task_panel_above_the_input_box() {
// The pane grows over the whole panel (plus the context row above it)
// so the run's task status is visible, instead of showing the single
// context row that used to land on the panel's blank margin.
assert_eq!(compact_frame(&task_panel_screen()), Some((2, 14)));
}
#[test]
fn task_panel_frame_survives_the_in_flight_shape() {
// While the turn runs the same rows hang under the spinner row with a
// `⎿` gutter and carry no header.
let screen = rows(&[
"● Setting up the task list.", // 0
"", // 1
"· Swirling… (9s · ↓ 2.3k tokens)", // 2: context row
" ⎿ ◻ Capture ground truth screens", // 3
" ◻ Fix interactive pane sizing", // 4
" … +59 pending", // 5
"", // 6
&format!("{RULE} minimal ──"), // 7: top rule
"", // 8
RULE, // 9
"Session: ▓▓░ Context | Opus", // 10
]);
assert_eq!(compact_frame(&screen), Some((2, 10)));
}
#[test]
fn task_panel_yields_to_the_input_box_when_the_pane_is_short() {
// The panel is context about the run; the input box is what the user
// drives. A pane too short for both must drop the panel, not the box.
let screen = task_panel_screen();
// Room for everything: panel included (rows 2..14).
assert_eq!(compact_view_range(&screen, 15, 13), (2, 14));
// Room for 5 rows: falls back to one context row above the box, so the
// input line (row 12) and the statusLine (row 14) stay visible.
assert_eq!(compact_view_range(&screen, 15, 5), (10, 14));
}
#[test]
fn plain_tool_result_gutter_does_not_grow_the_pane() {
// `⎿` is every tool result's gutter. Only a task glyph behind it counts
// as the task panel — otherwise the pane would grow on every Read/Bash.
let screen = rows(&[
"● Reading the file.", // 0
" ⎿ Read 20 lines", // 1
"", // 2
&format!("{RULE} minimal ──"), // 3: top rule
"", // 4
RULE, // 5
"Session: ▓▓░ Context | Opus", // 6
]);
// One context row above the box, as before.
assert_eq!(compact_frame(&screen), Some((2, 6)));
}
#[test]
fn no_input_box_yields_none() {
// Startup banner only — no rules, so the caller falls back.

View File

@@ -445,6 +445,10 @@ fn bind_fresh_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, model: &
if !a.sessions.iter().any(|s| s.key == sid) {
a.sessions.push(Session::new(sid.clone(), model.to_string()));
}
// Remember the exact `--model` argument: the transcript only records
// the base model id, so this is the one place a `[1m]` (1M-context)
// pick can survive into a later resume.
a.set_spawn_model(&sid, model);
}
bind_new_pane(eui, app, t, Some(&sid));
}
@@ -496,9 +500,13 @@ fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
/// Spawn (or replace) the embedded pane resuming a past session by UUID.
/// Any existing pane (live or dead) is killed and replaced — this is the
/// 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.
fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
kill_current_embed(eui, app);
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id) {
let model = app.lock().unwrap().resume_model(session_id);
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id, &model) {
Ok(t) => {
{
// Pre-fill the feed from the on-disk transcript so it isn't
@@ -514,6 +522,15 @@ fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
{
a.sessions.push(s);
}
a.set_spawn_model(session_id, &model);
a.status = if model.is_empty() {
format!("resumed {}", &session_id[..8.min(session_id.len())])
} else {
format!(
"resumed {} on {model}",
&session_id[..8.min(session_id.len())]
)
};
}
bind_new_pane(eui, app, t, Some(session_id));
}
@@ -939,17 +956,20 @@ fn draw(
// Whole screen minus the footer and the 1-row Min(1) the feed area
// keeps (layout below still reserves it).
PaneView::Full => total.saturating_sub(2),
// Both cropped views size themselves from what the child actually
// drew, smoothed by the same hysteresis so the PTY isn't resized
// (→ Ink repaint → flicker) on every per-frame wobble.
PaneView::Interactive => {
let h = a
// Until the prompt appears on screen (the tap grows the pane a
// beat early) fall back to the tap's estimate from the tool
// JSON, then to three quarters of the screen.
let measured = eui.term.as_ref().and_then(EmbeddedTerm::interactive_rows).or(a
.embed_grow_rows
.map(|r| r.saturating_add(3)) // + borders + statusLine row
.unwrap_or((total as u32 * 75 / 100) as u16);
h.clamp(EMBED_MIN.min(cap), cap)
.map(|r| r.saturating_add(3))); // + borders + hint row
let inner = eui.compact_height(measured);
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
}
PaneView::Compact => {
// Hysteresis smooths the per-frame measurement so the PTY
// isn't resized (→ Ink repaint → flicker) on every wobble
// during subagent turns / `@`/`/` menu filtering.
let measured = eui.term.as_ref().and_then(EmbeddedTerm::compact_rows);
let inner = eui.compact_height(measured);
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
@@ -1391,21 +1411,25 @@ fn draw(
// no chrome crop); the compact/interactive pane crops Claude Code
// chrome, so the PTY gets pad rows to draw what we hide.
//
// Compact is the one case where the PTY must NOT track the visible
// pane height: that height is itself derived from measuring what's
// already on the child's screen (`compact_rows`), so tying the PTY
// to it creates a feedback loop — an `@`/`/` menu (or a big paste)
// that suddenly needs many more rows than the *current* PTY+pad
// never gets measured, because Ink only ever draws as many rows as
// the PTY currently reports, so the pane can get stuck small
// forever. Give Compact a PTY roomy enough for the whole screen
// (rendering still only shows the cropped window via
// `compact_view_range`), so Ink always has enough space to draw a
// full box + menu in one shot.
let pty_rows = if pane_view == PaneView::Compact {
f.area().height
} else {
// Only fullscreen ties the PTY to the visible pane. Every cropped
// view derives its height by measuring what's already on the
// child's screen, so tying the PTY to that height is a feedback
// loop — Ink only ever draws as many rows as the PTY reports, so a
// view that suddenly needs more rows than PTY+pad can never draw
// them, can never be measured, and stays stuck small.
//
// Compact hits this with an `@`/`/` menu or a big paste.
// Interactive hits it harder: Claude Code *lays out* its
// AskUserQuestion / ExitPlanMode prompt against the reported rows
// and switches to a truncated rendering when they are few, so a
// pane-sized PTY made the child itself hide parts of the prompt —
// no amount of framing on our side could bring them back. A
// screen-tall PTY lets Ink draw the prompt in full; the render
// window still shows only the framed region.
let pty_rows = if pane_view == PaneView::Full {
inner.height
} else {
f.area().height
};
et.resize(pty_rows, inner.width, pane_view != PaneView::Full);
if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) {