new session fixes

This commit is contained in:
Jonas H
2026-06-26 13:10:10 +02:00
parent 5669d30c27
commit 9c5356c6ba
5 changed files with 294 additions and 125 deletions

View File

@@ -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<Mutex<App>> 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 <uuid>` 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_<uuid>`; `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/<cwd with / → ->/*.jsonl`
as dimmed stubs (deduped by uuid — a live session's file is on disk too).

View File

@@ -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<bool>,
/// 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<String>,
/// 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<String>,
/// 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<String>) -> 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": "<system-reminder>noise</system-reminder>"},
@@ -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 = "<command-name>/commit</command-name>\n<command-args>-a</command-args>\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 {
// <system-reminder> 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",

View File

@@ -88,10 +88,20 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
.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<Response> {
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());

View File

@@ -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 <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<Self> {
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 <session_id>` 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<Self> {
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<Self> {
@@ -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);

190
src/ui.rs
View File

@@ -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 <new uuid>`
/// `a` model picker: always spawn a *fresh* `claude --session-id <new uuid>`
/// (optionally `--model <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 {