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. *and* slash-command machinery) used for turn-tree labels.
Dedup drops only true resends (the just-recorded prompt is still Dedup drops only true resends (the just-recorded prompt is still
the tail entry), so verbatim repeats in later turns survive. the tail entry), so verbatim repeats in later turns survive.
Forwards via reqwest, streams the response back unbuffered, tees Also reads the `x-claude-cloak-pane` header (passed to `Tap::new`
SSE 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/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, src/app.rs Arc<Mutex<App>> shared state; Tap = one in-flight tapped request,
translates SSE events → session Entries (Drop closes it out) 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 src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
portable-pty routed through the proxy; wezterm-term models the portable-pty routed through the proxy; wezterm-term models the
screen (and answers terminal queries); renderer paints cells 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 → 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. builds `user_…_session_<uuid>`; `proxy::session_key` handles both.
Concurrent requests (subagents) share a session but each `Tap` tracks its own Concurrent requests (subagents) share a session but each `Tap` tracks its own
current entry index — entries/sessions are append-only, so indices stay stable. 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** - **One app instance = one proxy port = at most one embedded claude**
(`EmbedUi::term` / `App::embed_session`, matched by the `--session-id` / (`EmbedUi::term` / `App::embed_token` → learned `App::embed_session`).
`--resume` UUID we spawn with). Every other live session is an external `kill_current_embed` is the single teardown path and `bind_new_pane` the
claude pointed at our port: observable, never attachable. 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 - The session list merges live sessions (first, indices stable) with this
directory's past sessions from `~/.claude/projects/<cwd with / → ->/*.jsonl` 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). 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 /// draw (entry heights live in the render cache, so the key handler can't
/// compute the offset itself), then cleared. /// compute the offset itself), then cleared.
pub prompt_jump: Option<bool>, pub prompt_jump: Option<bool>,
/// Session UUID of the embedded `claude` pane (src/term.rs), so the tap /// Session UUID of the embedded `claude` pane (src/term.rs). This is now
/// can recognise traffic belonging to it. /// *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 /// Invariant: one app instance = one proxy port = at most one embedded
/// claude instance. Every other live session in `sessions` is an external /// 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 /// can only `--resume` it, which is guarded because the external instance
/// may still be running). /// may still be running).
pub embed_session: Option<String>, 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 /// True while the embedded session shows a large interactive prompt
/// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet /// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet
/// echoed back) — the UI gives the pane more rows while set. /// echoed back) — the UI gives the pane more rows while set.
@@ -155,12 +167,36 @@ impl App {
turn_dirty: false, turn_dirty: false,
prompt_jump: None, prompt_jump: None,
embed_session: None, embed_session: None,
embed_token: None,
pane_focused: false,
embed_grow: false, embed_grow: false,
embed_grow_rows: None, embed_grow_rows: None,
embed_clear_at: 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 /// Indices into `disk_sessions` that should appear as stubs: everything
/// not already present as a live session (a live session *is* on disk — /// not already present as a live session (a live session *is* on disk —
/// Claude Code writes the JSONL continuously — so dedupe by uuid). /// Claude Code writes the JSONL continuously — so dedupe by uuid).
@@ -767,21 +803,36 @@ pub struct Tap {
} }
impl 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 sidx = {
let mut a = lock_app(&app); 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, Some(i) => i,
None => { None => {
a.sessions.push(Session::new(key, model.clone())); a.sessions.push(Session::new(key, model.clone()));
let idx = a.sessions.len() - 1; 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
} }
}; };
// 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].active += 1;
a.sessions[sidx].last_activity = Instant::now(); a.sessions[sidx].last_activity = Instant::now();
sidx sidx
@@ -1010,7 +1061,7 @@ mod tests {
#[test] #[test]
fn tool_result_attaches_to_entry() { fn tool_result_attaches_to_entry() {
let app: SharedApp = Arc::new(Mutex::new(App::new())); 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( tap.handle(
"content_block_start", "content_block_start",
&json!({"content_block": {"type": "tool_use", "id": "toolu_01", "name": "Bash"}}), &json!({"content_block": {"type": "tool_use", "id": "toolu_01", "name": "Bash"}}),
@@ -1046,7 +1097,7 @@ mod tests {
fn interactive_tool_toggles_embed_grow() { fn interactive_tool_toggles_embed_grow() {
let app: SharedApp = Arc::new(Mutex::new(App::new())); let app: SharedApp = Arc::new(Mutex::new(App::new()));
app.lock().unwrap().embed_session = Some("emb".into()); 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( tap.handle(
"content_block_start", "content_block_start",
&json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}),
@@ -1070,7 +1121,7 @@ mod tests {
#[test] #[test]
fn user_prompt_recorded_once_and_injections_skipped() { fn user_prompt_recorded_once_and_injections_skipped() {
let app: SharedApp = Arc::new(Mutex::new(App::new())); 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": [ let body = json!({"tools": [{"name": "Bash"}], "messages": [
{"role": "user", "content": [ {"role": "user", "content": [
{"type": "text", "text": "<system-reminder>noise</system-reminder>"}, {"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 // A verbatim repeat ("continue") in a *later* turn must show — only an
// immediate resend (same request, nothing streamed since) is deduped. // immediate resend (same request, nothing streamed since) is deduped.
let app: SharedApp = Arc::new(Mutex::new(App::new())); 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": [ let body = json!({"tools": [{"name": "Bash"}], "messages": [
{"role": "user", "content": "continue"} {"role": "user", "content": "continue"}
]}); ]});
@@ -1156,7 +1207,7 @@ mod tests {
#[test] #[test]
fn system_size_and_tools_surfaced_once_then_on_change() { fn system_size_and_tools_surfaced_once_then_on_change() {
let app: SharedApp = Arc::new(Mutex::new(App::new())); 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| { let turn = |sys: &str, tools: Value, prompt: &str| {
json!({"system": sys, "tools": tools, "messages": [ json!({"system": sys, "tools": tools, "messages": [
{"role": "user", "content": prompt} {"role": "user", "content": prompt}
@@ -1211,7 +1262,7 @@ mod tests {
// verbatim; only the turn-tree label projection drops them. // 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 raw = "<command-name>/commit</command-name>\n<command-args>-a</command-args>\nthe rest";
let app: SharedApp = Arc::new(Mutex::new(App::new())); 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( record_user_prompt(
&app, &app,
"abc", "abc",
@@ -1235,7 +1286,7 @@ mod tests {
// <system-reminder> prepended *inside the same text block* as the real // <system-reminder> prepended *inside the same text block* as the real
// prompt — the old whole-block filter dropped it entirely. // prompt — the old whole-block filter dropped it entirely.
let app: SharedApp = Arc::new(Mutex::new(App::new())); 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( record_user_prompt(
&app, &app,
"abc", "abc",
@@ -1307,7 +1358,7 @@ mod tests {
fn non_embed_session_never_grows() { fn non_embed_session_never_grows() {
let app: SharedApp = Arc::new(Mutex::new(App::new())); let app: SharedApp = Arc::new(Mutex::new(App::new()));
app.lock().unwrap().embed_session = Some("emb".into()); 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( tap.handle(
"content_block_start", "content_block_start",
&json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}),
@@ -1316,6 +1367,59 @@ mod tests {
assert!(!app.lock().unwrap().embed_grow); 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 { fn ds(uuid: &str) -> crate::sessions::DiskSession {
crate::sessions::DiskSession { crate::sessions::DiskSession {
uuid: uuid.into(), uuid: uuid.into(),
@@ -1441,7 +1545,7 @@ mod tests {
#[test] #[test]
fn unknown_tool_id_is_ignored() { fn unknown_tool_id_is_ignored() {
let app: SharedApp = Arc::new(Mutex::new(App::new())); 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( attach_tool_results(
&app, &app,
"abc", "abc",

View File

@@ -88,10 +88,20 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
.and_then(session_key) .and_then(session_key)
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .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 // Tool results ride along in the request body; surface them
// on the tool entries from the previous turn. // on the tool entries from the previous turn.
attach_tool_results(&ctx.app, &key, &v); 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. // After Tap::new: the session must exist for the entry to land.
record_user_prompt(&ctx.app, &key, &v); 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); let mut rb = ctx.client.request(parts.method.clone(), &url);
for (name, value) in parts.headers.iter() { for (name, value) in parts.headers.iter() {
// hop-by-hop / recomputed headers; accept-encoding stripped so the // 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!( if matches!(
name.as_str(), name.as_str(),
"host" | "content-length" | "transfer-encoding" | "connection" "host" | "content-length" | "transfer-encoding" | "connection"
| "accept-encoding" | "expect" | "accept-encoding" | "expect"
) { ) || name.as_str() == crate::term::PANE_TOKEN_HEADER
{
continue; continue;
} }
rb = rb.header(name.clone(), value.clone()); 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 /// Session UUID passed to `claude --session-id`; lets the tap recognise
/// which proxied session belongs to this pane. /// which proxied session belongs to this pane.
pub session_id: String, 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). /// Actual PTY rows (visible rows + pad when cropping is active).
pty_rows: u16, pty_rows: u16,
cols: 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 { impl EmbeddedTerm {
/// Spawn `claude` in a fresh PTY, routed through our proxy. `model`, when /// Spawn `claude` in a fresh PTY, routed through our proxy. `model`, when
/// non-empty, is passed as `--model <model>` (a Claude Code alias like /// non-empty, is passed as `--model <model>` (a Claude Code alias like
/// `opus`/`sonnet`/`haiku` or a full model name). /// `opus`/`sonnet`/`haiku` or a full model name).
pub fn spawn(port: u16, rows: u16, cols: u16, model: &str) -> anyhow::Result<Self> { pub fn spawn(port: u16, rows: u16, cols: u16, model: &str) -> anyhow::Result<Self> {
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
let pane_token = uuid::Uuid::new_v4().to_string();
let mut cmd = CommandBuilder::new("claude"); let mut cmd = CommandBuilder::new("claude");
cmd.args(["--session-id", &session_id]); cmd.args(["--session-id", &session_id]);
if !model.is_empty() { if !model.is_empty() {
cmd.args(["--model", model]); cmd.args(["--model", model]);
} }
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}")); 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() { if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd); 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. /// Spawn `claude --resume <session_id>` to continue a past session.
/// A resumed session keeps its original session UUID in request /// A resumed session usually keeps its original UUID in request metadata,
/// metadata (verified against Claude Code 2.1.x), so the tap correlates /// but Claude Code is not guaranteed to (it can mint a fresh id on resume),
/// traffic via the resumed UUID itself. `--session-id` must NOT be /// so the tap still correlates by the injected pane token and *rebinds* the
/// passed alongside `--resume` (rejected without `--fork-session`). /// 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> { 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"); let mut cmd = CommandBuilder::new("claude");
cmd.args(["--resume", session_id]); cmd.args(["--resume", session_id]);
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}")); 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() { if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd); 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( fn spawn_cmd(
cmd: CommandBuilder, cmd: CommandBuilder,
session_id: String, session_id: String,
pane_token: String,
rows: u16, rows: u16,
cols: u16, cols: u16,
) -> anyhow::Result<Self> { ) -> 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 { pub fn exited(&self) -> bool {
@@ -659,7 +677,7 @@ mod tests {
let mut cmd = CommandBuilder::new("sh"); let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", "printf 'hello-embed'; sleep 1"]); cmd.args(["-c", "printf 'hello-embed'; sleep 1"]);
let area = Rect::new(0, 0, 40, 5); 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); let deadline = Instant::now() + Duration::from_secs(5);
loop { loop {
let mut buf = Buffer::empty(area); 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; eui.fullscreen = false;
// A dead child is dropped on hide so the next toggle respawns. // A dead child is dropped on hide so the next toggle respawns.
if eui.term.as_ref().is_some_and(|t| t.exited()) { if eui.term.as_ref().is_some_and(|t| t.exited()) {
eui.term = None; kill_current_embed(eui, app);
if let Some(old) = app.lock().unwrap().embed_session.take() {
eui.past_embeds.insert(old);
}
} }
return; return;
} }
show_embed_pane(eui, app); 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. /// Show (spawning if needed) the claude pane and give it keyboard focus.
/// Moves the selection onto the embedded session — the pane is only drawn /// A live child is reused (instant reveal); a dead one is replaced.
/// while its session is selected.
fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) {
if eui.term.as_ref().is_some_and(|t| t.exited()) { if eui.term.as_ref().is_some_and(|t| t.exited()) {
eui.term = None; kill_current_embed(eui, app);
} }
if eui.term.is_none() { if eui.term.is_none() {
// Real dimensions are applied on the first draw via resize(). // Real dimensions are applied on the first draw via resize().
match EmbeddedTerm::spawn(eui.port, 20, 80, "") { match EmbeddedTerm::spawn(eui.port, 20, 80, "") {
Ok(t) => { Ok(t) => bind_fresh_pane(eui, app, 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);
}
Err(e) => { Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}"); app.lock().unwrap().status = format!("claude spawn failed: {e}");
return; 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.visible = true;
eui.claude_focused = 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 /// (optionally `--model <model>`), killing any current pane first. Unlike
/// `show_embed_pane` this never reuses an existing child — the point of `n` /// `show_embed_pane` this never reuses an existing child — the point is to
/// is to start a brand-new session without resume + /clear. /// start a brand-new session without resume + /clear.
fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) { fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
// Drop kills the child process (see EmbeddedTerm::drop). kill_current_embed(eui, app);
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;
}
match EmbeddedTerm::spawn(eui.port, 20, 80, model) { match EmbeddedTerm::spawn(eui.port, 20, 80, model) {
Ok(t) => { Ok(t) => bind_fresh_pane(eui, app, t, model),
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);
}
Err(e) => { Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}"); app.lock().unwrap().status = format!("claude spawn failed: {e}");
return; 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 /// 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`. /// only expensive path, and it only runs from an explicit ctrl-↓ / `c`.
fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) { fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
// Drop kills the child process (see EmbeddedTerm::drop). kill_current_embed(eui, app);
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;
}
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id) { match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id) {
Ok(t) => { 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); bind_new_pane(eui, app, t, Some(session_id));
a.follow = true;
drop(a);
eui.term = Some(t);
} }
Err(e) => { Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {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. // kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere.
let selected_is_embed = let selected_is_embed =
a.embed_session.is_some() && a.selected_key().as_deref() == a.embed_session.as_deref(); 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 { if !show_embed {
// An invisible pane must not swallow keystrokes (the selection can // An invisible pane must not swallow keystrokes (the selection can
// move under us, e.g. a new session auto-jump). // move under us, e.g. a new session auto-jump).
eui.claude_focused = false; eui.claude_focused = false;
eui.fullscreen = 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 { let pane_view = if eui.fullscreen {
PaneView::Full PaneView::Full
} else if a.embed_grow { } else if a.embed_grow {