diff --git a/CLAUDE.md b/CLAUDE.md index 1fdaf24..757a3f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,8 +30,10 @@ src/proxy.rs axum fallback handler: buffers request body (for session metadata *and* slash-command machinery) used for turn-tree labels. Dedup drops only true resends (the just-recorded prompt is still the tail entry), so verbatim repeats in later turns survive. - Forwards via reqwest, streams the response back unbuffered, tees - SSE + Also reads the `x-claude-cloak-pane` header (passed to `Tap::new` + to bind the embedded pane — see the embed-identity invariant) and + strips it before forwarding. Forwards via reqwest, streams the + response back unbuffered, tees SSE src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8 src/app.rs Arc> shared state; Tap = one in-flight tapped request, translates SSE events → session Entries (Drop closes it out) @@ -75,7 +77,10 @@ src/sessions.rs on-disk session history: background scanner thread keeps src/term.rs embedded claude pane: spawns `claude --session-id ` in a portable-pty routed through the proxy; wezterm-term models the screen (and answers terminal queries); renderer paints cells - into the ratatui buffer + into the ratatui buffer. Each spawn injects a fresh per-pane + token via `ANTHROPIC_CUSTOM_HEADERS` (`PANE_TOKEN_HEADER` = + `x-claude-cloak-pane`), the correlation handle the proxy uses to + recognise the pane's own traffic (see the embed-identity invariant) ``` Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state → @@ -99,10 +104,28 @@ UI thread redraws on its own tick (no channel; just the mutex). builds `user_…_session_`; `proxy::session_key` handles both. Concurrent requests (subagents) share a session but each `Tap` tracks its own current entry index — entries/sessions are append-only, so indices stay stable. +- **Embed identity is learned from traffic, never assumed from `--session-id`.** + Claude Code's interactive `--session-id` is *not* guaranteed to equal the id + it reports in request metadata (and a `--resume` can mint a fresh one), so the + pane is correlated by a token we control: `term.rs` injects a per-spawn + `x-claude-cloak-pane` header (`ANTHROPIC_CUSTOM_HEADERS`), the proxy reads it + (and strips it before forwarding), and `Tap::new` *binds* `App::embed_session` + to whatever id that tagged request actually carries (`App::bind_embed_session` + rebinds + renames a provisional resume row if they differ). Selection policy + follows: the embed jumps the selection only on first bind; a brand-new + *external* session auto-jumps so a fresh `/clear` is visible **unless** + `App::pane_focused` (mirrored from the UI each frame) — never steal the + selection from a pane the user is driving. This is what made an `a`-spawned + session stream into the wrong row before. - **One app instance = one proxy port = at most one embedded claude** - (`EmbedUi::term` / `App::embed_session`, matched by the `--session-id` / - `--resume` UUID we spawn with). Every other live session is an external - claude pointed at our port: observable, never attachable. + (`EmbedUi::term` / `App::embed_token` → learned `App::embed_session`). + `kill_current_embed` is the single teardown path and `bind_new_pane` the + single registration path, so pane identity + grow/clear flags can't drift + across the spawn/replace call sites. Every other live session is an external + claude pointed at our port: observable, never attachable. The pane stays + visible while it holds keyboard focus even if the selection isn't on its + session yet (its id is still being learned); only an intentional ctrl-↑ / + tab-away hides it. - The session list merges live sessions (first, indices stable) with this directory's past sessions from `~/.claude/projects//*.jsonl` as dimmed stubs (deduped by uuid — a live session's file is on disk too). diff --git a/src/app.rs b/src/app.rs index 7570396..7b4f8df 100644 --- a/src/app.rs +++ b/src/app.rs @@ -72,8 +72,10 @@ pub struct App { /// draw (entry heights live in the render cache, so the key handler can't /// compute the offset itself), then cleared. pub prompt_jump: Option, - /// Session UUID of the embedded `claude` pane (src/term.rs), so the tap - /// can recognise traffic belonging to it. + /// Session UUID of the embedded `claude` pane (src/term.rs). This is now + /// *learned* from the pane's first tagged request (see `embed_token`), + /// not assumed from the `--session-id` we spawned with — Claude Code does + /// not guarantee they match. `None` until that first request binds it. /// /// Invariant: one app instance = one proxy port = at most one embedded /// claude instance. Every other live session in `sessions` is an external @@ -81,6 +83,16 @@ pub struct App { /// can only `--resume` it, which is guarded because the external instance /// may still be running). pub embed_session: Option, + /// The current embedded child's pane token (`EmbeddedTerm::pane_token`), + /// echoed back on every request it makes via the `x-claude-cloak-pane` + /// header. The tap matches this to recognise the pane's traffic regardless + /// of which session id Claude Code reports, then binds `embed_session` to + /// that id. Set on spawn, cleared on kill. + pub embed_token: Option, + /// Mirror of the UI's "the claude pane has keyboard focus" state, written + /// by `draw` each frame so the tap (running off-thread) can avoid yanking + /// the selection away from a pane the user is actively driving. + pub pane_focused: bool, /// True while the embedded session shows a large interactive prompt /// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet /// echoed back) — the UI gives the pane more rows while set. @@ -155,12 +167,36 @@ impl App { turn_dirty: false, prompt_jump: None, embed_session: None, + embed_token: None, + pane_focused: false, embed_grow: false, embed_grow_rows: None, embed_clear_at: None, } } + /// Bind the embedded pane to the session id its traffic actually reports. + /// Returns true when the binding changed (a fresh bind, or a rebind when + /// Claude Code reported a different id than we spawned with) — the caller + /// uses that to jump the selection to the pane exactly once, not on every + /// request. A provisional row created under the old id (e.g. a resume's + /// pre-loaded transcript) is renamed onto the real id so its live traffic + /// and on-disk view stay one session. + pub fn bind_embed_session(&mut self, key: &str) -> bool { + if self.embed_session.as_deref() == Some(key) { + return false; + } + if let Some(prev) = self.embed_session.take() + && prev != key + && !self.sessions.iter().any(|s| s.key == key) + && let Some(s) = self.sessions.iter_mut().find(|s| s.key == prev) + { + s.key = key.to_string(); + } + self.embed_session = Some(key.to_string()); + true + } + /// 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). @@ -767,21 +803,36 @@ pub struct Tap { } impl Tap { - pub fn new(app: SharedApp, key: String, model: String) -> Self { + /// `pane_token` is the `x-claude-cloak-pane` header value when the request + /// came from our embedded pane (`None` for external sessions). + pub fn new(app: SharedApp, key: String, model: String, pane_token: Option) -> Self { let sidx = { let mut a = lock_app(&app); - let sidx = match a.sessions.iter().position(|s| s.key == key) { + // Does this request belong to the embedded pane we spawned? If so, + // (re)bind the embed to the id its traffic reports — this is how we + // learn the real session id instead of trusting `--session-id`. + let is_embed = pane_token.is_some() && pane_token == a.embed_token; + let newly_bound = is_embed && a.bind_embed_session(&key); + let existing = a.sessions.iter().position(|s| s.key == key); + let sidx = match existing { Some(i) => i, None => { a.sessions.push(Session::new(key, model.clone())); - let idx = a.sessions.len() - 1; - // Auto-jump to every new session so a fresh `/clear` in - // Claude Code is immediately visible without manual switching. - a.selected = idx; - a.follow = true; - idx + a.sessions.len() - 1 } }; + // Selection / follow policy: + // - The embedded pane jumps the selection only the first time it + // binds, so the user can read another session's feed while the + // pane keeps running without being yanked back every turn. + // - A brand-new *external* session auto-jumps so a fresh `/clear` + // is immediately visible — unless the user is actively driving + // the pane, in which case stealing the selection would hide it + // (the bug this whole change fixes). + if newly_bound || (existing.is_none() && !is_embed && !a.pane_focused) { + a.selected = sidx; + a.follow = true; + } a.sessions[sidx].active += 1; a.sessions[sidx].last_activity = Instant::now(); sidx @@ -1010,7 +1061,7 @@ mod tests { #[test] fn tool_result_attaches_to_entry() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - let mut tap = Tap::new(app.clone(), "abc".into(), "claude-x".into()); + let mut tap = Tap::new(app.clone(), "abc".into(), "claude-x".into(), None); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_01", "name": "Bash"}}), @@ -1046,7 +1097,7 @@ mod tests { fn interactive_tool_toggles_embed_grow() { let app: SharedApp = Arc::new(Mutex::new(App::new())); app.lock().unwrap().embed_session = Some("emb".into()); - let mut tap = Tap::new(app.clone(), "emb".into(), "claude-x".into()); + let mut tap = Tap::new(app.clone(), "emb".into(), "claude-x".into(), None); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), @@ -1070,7 +1121,7 @@ mod tests { #[test] fn user_prompt_recorded_once_and_injections_skipped() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); let body = json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": [ {"type": "text", "text": "noise"}, @@ -1129,7 +1180,7 @@ mod tests { // A verbatim repeat ("continue") in a *later* turn must show — only an // immediate resend (same request, nothing streamed since) is deduped. let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); let body = json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": "continue"} ]}); @@ -1156,7 +1207,7 @@ mod tests { #[test] fn system_size_and_tools_surfaced_once_then_on_change() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); let turn = |sys: &str, tools: Value, prompt: &str| { json!({"system": sys, "tools": tools, "messages": [ {"role": "user", "content": prompt} @@ -1211,7 +1262,7 @@ mod tests { // verbatim; only the turn-tree label projection drops them. let raw = "/commit\n-a\nthe rest"; let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); record_user_prompt( &app, "abc", @@ -1235,7 +1286,7 @@ mod tests { // prepended *inside the same text block* as the real // prompt — the old whole-block filter dropped it entirely. let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); record_user_prompt( &app, "abc", @@ -1307,7 +1358,7 @@ mod tests { fn non_embed_session_never_grows() { let app: SharedApp = Arc::new(Mutex::new(App::new())); app.lock().unwrap().embed_session = Some("emb".into()); - let mut tap = Tap::new(app.clone(), "other".into(), "claude-x".into()); + let mut tap = Tap::new(app.clone(), "other".into(), "claude-x".into(), None); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), @@ -1316,6 +1367,59 @@ mod tests { assert!(!app.lock().unwrap().embed_grow); } + #[test] + fn pane_token_binds_and_rebinds_embed_session() { + // A resume provisionally binds embed_session to the uuid we spawned + // with and pre-loads its row. If Claude Code then reports a *different* + // id in the tagged request, the embed must rebind to the real id and + // the provisional row is renamed onto it (one session, not two). + let app: SharedApp = Arc::new(Mutex::new(App::new())); + { + let mut a = app.lock().unwrap(); + a.embed_token = Some("tok".into()); + a.embed_session = Some("provisional".into()); + a.sessions.push(Session::new("provisional".into(), "(resumed)".into())); + } + drop(Tap::new(app.clone(), "real".into(), "m".into(), Some("tok".into()))); + let a = app.lock().unwrap(); + assert_eq!(a.embed_session.as_deref(), Some("real")); + assert_eq!(a.sessions.len(), 1, "provisional row renamed, not duplicated"); + assert_eq!(a.sessions[0].key, "real"); + assert_eq!(a.selected_key().as_deref(), Some("real"), "selection jumps on bind"); + } + + #[test] + fn focused_pane_not_stolen_by_new_external_session() { + // The bug this whole change fixes: a brand-new session streaming while + // the user is driving the embedded pane must NOT steal the selection + // (which hid the focused pane). + let app: SharedApp = Arc::new(Mutex::new(App::new())); + { + let mut a = app.lock().unwrap(); + a.embed_token = Some("tok".into()); + a.pane_focused = true; + } + // The pane's own first request binds + selects it. + drop(Tap::new(app.clone(), "embed".into(), "m".into(), Some("tok".into()))); + assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("embed")); + // A new external session streams: selection must stay on the embed. + drop(Tap::new(app.clone(), "external".into(), "m".into(), None)); + let a = app.lock().unwrap(); + assert_eq!(a.selected_key().as_deref(), Some("embed")); + assert_eq!(a.sessions.len(), 2, "external session is still tracked"); + } + + #[test] + fn new_session_autojumps_when_pane_unfocused() { + // With no pane focused, a fresh session still auto-jumps so a `/clear` + // in an external claude is immediately visible. + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new(app.clone(), "first".into(), "m".into(), None)); + assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("first")); + drop(Tap::new(app.clone(), "second".into(), "m".into(), None)); + assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("second")); + } + fn ds(uuid: &str) -> crate::sessions::DiskSession { crate::sessions::DiskSession { uuid: uuid.into(), @@ -1441,7 +1545,7 @@ mod tests { #[test] fn unknown_tool_id_is_ignored() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); attach_tool_results( &app, "abc", diff --git a/src/proxy.rs b/src/proxy.rs index 2077fe4..cbcb465 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -88,10 +88,20 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { .and_then(session_key) .unwrap_or("unknown") .to_string(); + // The pane token (if any) tells us this request belongs to *our* + // embedded claude — correlation we control, independent of whatever + // session id Claude Code reports. The real session id is still `key` + // (read from metadata); the token just lets the tap bind the embed to + // it. External sessions carry no token and key by metadata as before. + let pane_token = parts + .headers + .get(crate::term::PANE_TOKEN_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); // Tool results ride along in the request body; surface them // on the tool entries from the previous turn. attach_tool_results(&ctx.app, &key, &v); - tap = Some(Tap::new(ctx.app.clone(), key.clone(), model)); + tap = Some(Tap::new(ctx.app.clone(), key.clone(), model, pane_token)); // After Tap::new: the session must exist for the entry to land. record_user_prompt(&ctx.app, &key, &v); } @@ -99,12 +109,14 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { let mut rb = ctx.client.request(parts.method.clone(), &url); for (name, value) in parts.headers.iter() { // hop-by-hop / recomputed headers; accept-encoding stripped so the - // upstream sends an uncompressed stream we can parse in transit. + // upstream sends an uncompressed stream we can parse in transit; + // our own pane-token header is consumed locally, never forwarded. if matches!( name.as_str(), "host" | "content-length" | "transfer-encoding" | "connection" | "accept-encoding" | "expect" - ) { + ) || name.as_str() == crate::term::PANE_TOKEN_HEADER + { continue; } rb = rb.header(name.clone(), value.clone()); diff --git a/src/term.rs b/src/term.rs index a1f28d3..1905e2b 100644 --- a/src/term.rs +++ b/src/term.rs @@ -72,47 +72,65 @@ pub struct EmbeddedTerm { /// Session UUID passed to `claude --session-id`; lets the tap recognise /// which proxied session belongs to this pane. pub session_id: String, + /// A fresh per-spawn token injected as the `x-claude-cloak-pane` request + /// header (via `ANTHROPIC_CUSTOM_HEADERS`). The proxy keys this pane's + /// traffic by the token, *not* by `session_id`: Claude Code's interactive + /// `--session-id` is not guaranteed to be the id it reports in request + /// metadata, so the real session id is *learned* from the first tagged + /// request rather than assumed. Unique per spawn so a killed child's + /// in-flight requests can never be misattributed to its replacement. + pub pane_token: String, /// Actual PTY rows (visible rows + pad when cropping is active). pty_rows: u16, cols: u16, } +/// HTTP header carrying the pane token; the proxy reads it to bind this pane's +/// traffic and strips it before forwarding upstream. +pub const PANE_TOKEN_HEADER: &str = "x-claude-cloak-pane"; + impl EmbeddedTerm { /// Spawn `claude` in a fresh PTY, routed through our proxy. `model`, when /// non-empty, is passed as `--model ` (a Claude Code alias like /// `opus`/`sonnet`/`haiku` or a full model name). pub fn spawn(port: u16, rows: u16, cols: u16, model: &str) -> anyhow::Result { let session_id = uuid::Uuid::new_v4().to_string(); + let pane_token = uuid::Uuid::new_v4().to_string(); let mut cmd = CommandBuilder::new("claude"); cmd.args(["--session-id", &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() { cmd.cwd(cwd); } - Self::spawn_cmd(cmd, session_id, rows, cols) + Self::spawn_cmd(cmd, session_id, pane_token, rows, cols) } /// Spawn `claude --resume ` to continue a past session. - /// A resumed session keeps its original session UUID in request - /// metadata (verified against Claude Code 2.1.x), so the tap correlates - /// traffic via the resumed UUID itself. `--session-id` must NOT be - /// passed alongside `--resume` (rejected without `--fork-session`). + /// A resumed session usually keeps its original UUID in request metadata, + /// 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 { + let pane_token = uuid::Uuid::new_v4().to_string(); let mut cmd = CommandBuilder::new("claude"); cmd.args(["--resume", session_id]); 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() { cmd.cwd(cwd); } - Self::spawn_cmd(cmd, session_id.to_string(), rows, cols) + Self::spawn_cmd(cmd, session_id.to_string(), pane_token, rows, cols) } fn spawn_cmd( cmd: CommandBuilder, session_id: String, + pane_token: String, rows: u16, cols: u16, ) -> anyhow::Result { @@ -164,7 +182,7 @@ impl EmbeddedTerm { }); } - Ok(Self { term, master: pty.master, killer, exited, session_id, pty_rows: rows + PTY_PAD, cols }) + Ok(Self { term, master: pty.master, killer, exited, session_id, pane_token, pty_rows: rows + PTY_PAD, cols }) } pub fn exited(&self) -> bool { @@ -659,7 +677,7 @@ mod tests { let mut cmd = CommandBuilder::new("sh"); cmd.args(["-c", "printf 'hello-embed'; sleep 1"]); let area = Rect::new(0, 0, 40, 5); - let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), 5, 40).unwrap(); + let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), "test-token".into(), 5, 40).unwrap(); let deadline = Instant::now() + Duration::from_secs(5); loop { let mut buf = Buffer::empty(area); diff --git a/src/ui.rs b/src/ui.rs index 47c09fd..54e6cc4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -325,93 +325,107 @@ fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { eui.fullscreen = false; // A dead child is dropped on hide so the next toggle respawns. if eui.term.as_ref().is_some_and(|t| t.exited()) { - eui.term = None; - if let Some(old) = app.lock().unwrap().embed_session.take() { - eui.past_embeds.insert(old); - } + kill_current_embed(eui, app); } return; } show_embed_pane(eui, app); } +/// Tear down the current embedded child (if any): dropping it kills the +/// process (`EmbeddedTerm::drop`), the learned session id is retired into +/// `past_embeds` (so a later `--resume` of it skips the liveness guard — we +/// know it's dead), and every per-pane flag is cleared. The single teardown +/// path every spawn/replace routes through, so pane identity and the +/// grow/clear flags can never drift between the four call sites. +fn kill_current_embed(eui: &mut EmbedUi, app: &SharedApp) { + if eui.term.take().is_none() { + return; + } + let mut a = app.lock().unwrap(); + if let Some(old) = a.embed_session.take() { + eui.past_embeds.insert(old); + } + a.embed_token = None; + a.embed_grow = false; + a.embed_grow_rows = None; + a.embed_clear_at = None; +} + +/// Register a freshly spawned pane as the embedded child: record its token +/// (so the tap recognises the pane's traffic and learns its real session id), +/// reset per-pane flags, and store the child. `session` is `Some(uuid)` only +/// for a resume — where we know the id up front and pre-load its transcript; +/// a fresh spawn passes `None` and lets the first tagged request bind the id. +fn bind_new_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, session: Option<&str>) { + let mut a = app.lock().unwrap(); + a.embed_token = Some(t.pane_token.clone()); + a.embed_session = session.map(str::to_string); + a.embed_grow = false; + a.embed_grow_rows = None; + a.embed_clear_at = None; + if let Some(key) = session { + a.select_key(key); + } + a.follow = true; + a.clear_turn_focus(); + drop(a); + eui.term = Some(t); +} + +/// Register a freshly spawned pane *and* switch the feed to it immediately: +/// create a provisional, empty session row keyed by the spawned `--session-id` +/// and select it, so a fresh `a` / first spawn clears the feed to the new blank +/// session at once instead of waiting for the first prompt's traffic to bind +/// it. If Claude Code later reports a different id, the tap renames this row +/// onto it (the same provisional-rename path a resume uses), so we never end up +/// with two rows for the one session. +fn bind_fresh_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, model: &str) { + let sid = t.session_id.clone(); + { + let mut a = app.lock().unwrap(); + if !a.sessions.iter().any(|s| s.key == sid) { + a.sessions.push(Session::new(sid.clone(), model.to_string())); + } + } + bind_new_pane(eui, app, t, Some(&sid)); +} + /// Show (spawning if needed) the claude pane and give it keyboard focus. -/// Moves the selection onto the embedded session — the pane is only drawn -/// while its session is selected. +/// A live child is reused (instant reveal); a dead one is replaced. fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { if eui.term.as_ref().is_some_and(|t| t.exited()) { - eui.term = None; + 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) => { - let mut a = app.lock().unwrap(); - if let Some(old) = a.embed_session.replace(t.session_id.clone()) { - eui.past_embeds.insert(old); - } - a.embed_grow = false; - // A fresh session has no traffic yet: give it a live row now - // so the selection (and the pane-visibility rule) has a key - // to point at. Tap::new finds this row by key and reuses it. - if !a.sessions.iter().any(|s| s.key == t.session_id) { - a.sessions - .push(Session::new(t.session_id.clone(), "(embedded)".into())); - } - drop(a); - eui.term = Some(t); - } + Ok(t) => bind_fresh_pane(eui, app, t, ""), Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); return; } } + } else { + // Reusing a live child: re-select its session if we've learned it. + let mut a = app.lock().unwrap(); + if let Some(key) = a.embed_session.clone() { + a.select_key(&key); + a.follow = true; + } } - let mut a = app.lock().unwrap(); - if let Some(key) = a.embed_session.clone() { - a.select_key(&key); - a.follow = true; - } - drop(a); eui.visible = true; eui.claude_focused = true; } -/// `n` model picker: always spawn a *fresh* `claude --session-id ` +/// `a` model picker: always spawn a *fresh* `claude --session-id ` /// (optionally `--model `), killing any current pane first. Unlike -/// `show_embed_pane` this never reuses an existing child — the point of `n` -/// is to start a brand-new session without resume + /clear. +/// `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) { - // Drop kills the child process (see EmbeddedTerm::drop). - if eui.term.take().is_some() { - let mut a = app.lock().unwrap(); - if let Some(old) = a.embed_session.take() { - eui.past_embeds.insert(old); - } - a.embed_grow = false; - a.embed_grow_rows = None; - a.embed_clear_at = None; - } + kill_current_embed(eui, app); match EmbeddedTerm::spawn(eui.port, 20, 80, model) { - Ok(t) => { - let mut a = app.lock().unwrap(); - if let Some(old) = a.embed_session.replace(t.session_id.clone()) { - eui.past_embeds.insert(old); - } - a.embed_grow = false; - // Give the fresh session a live row so the selection (and the - // pane-visibility rule) has a key to point at (Tap::new reuses it). - if !a.sessions.iter().any(|s| s.key == t.session_id) { - a.sessions - .push(Session::new(t.session_id.clone(), "(embedded)".into())); - } - let key = t.session_id.clone(); - a.select_key(&key); - a.follow = true; - a.clear_turn_focus(); - drop(a); - eui.term = Some(t); - } + Ok(t) => bind_fresh_pane(eui, app, t, model), Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); return; @@ -425,37 +439,25 @@ fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) { /// 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`. fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) { - // Drop kills the child process (see EmbeddedTerm::drop). - if eui.term.take().is_some() { - let mut a = app.lock().unwrap(); - if let Some(old) = a.embed_session.take() { - // That instance is dead now: its session needs no liveness guard. - eui.past_embeds.insert(old); - } - a.embed_grow = false; - a.embed_grow_rows = None; - a.embed_clear_at = None; - } + kill_current_embed(eui, app); match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id) { Ok(t) => { - let mut a = app.lock().unwrap(); - a.embed_session = Some(t.session_id.clone()); - a.embed_grow = false; - // Promote the session to a live row, pre-filled from the on-disk - // transcript: no API traffic flows until the next turn, so it - // would be blank. Always re-read the file — a cached view may - // have been built along a dead-branch path, while the resumed - // claude continues from the trunk. - a.history.remove(session_id); - if !a.sessions.iter().any(|s| s.key == session_id) - && let Some(s) = crate::sessions::load_history(session_id) { - a.sessions.push(s); + // Pre-fill the feed from the on-disk transcript so it isn't + // blank until the next turn streams. Always re-read (drop any + // cached path view) — the resume continues the trunk, which a + // dead-branch view wouldn't show. The bind below provisionally + // points `embed_session` at this uuid; if Claude Code reports a + // different id, the tap rebinds (renaming this row onto it). + let mut a = app.lock().unwrap(); + a.history.remove(session_id); + if !a.sessions.iter().any(|s| s.key == session_id) + && let Some(s) = crate::sessions::load_history(session_id) + { + a.sessions.push(s); + } } - a.select_key(session_id); - a.follow = true; - drop(a); - eui.term = Some(t); + bind_new_pane(eui, app, t, Some(session_id)); } Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); @@ -848,13 +850,23 @@ fn draw( // kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere. let selected_is_embed = a.embed_session.is_some() && a.selected_key().as_deref() == a.embed_session.as_deref(); - let show_embed = eui.visible && eui.term.is_some() && selected_is_embed; + // The pane stays visible while it holds keyboard focus even if the + // selection isn't (yet) on its session: its real session id is learned + // from the first request, and an involuntary selection move (another + // session's traffic) must never yank focus out of a pane the user is + // typing in. Intentional navigation still hides it — ctrl-↑ drops focus, + // after which the selection rule applies and tabbing away hides the pane. + let show_embed = + eui.visible && eui.term.is_some() && (selected_is_embed || eui.claude_focused); if !show_embed { // An invisible pane must not swallow keystrokes (the selection can // move under us, e.g. a new session auto-jump). eui.claude_focused = false; eui.fullscreen = false; } + // Mirror focus into shared state so the off-thread tap can avoid stealing + // the selection from a pane the user is actively driving. + a.pane_focused = eui.focused(); let pane_view = if eui.fullscreen { PaneView::Full } else if a.embed_grow {