new session fixes
This commit is contained in:
35
CLAUDE.md
35
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<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).
|
||||
|
||||
142
src/app.rs
142
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<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",
|
||||
|
||||
18
src/proxy.rs
18
src/proxy.rs
@@ -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());
|
||||
|
||||
34
src/term.rs
34
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 <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);
|
||||
|
||||
172
src/ui.rs
172
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;
|
||||
}
|
||||
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) => {
|
||||
{
|
||||
// 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.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);
|
||||
}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user