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.
|
*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).
|
||||||
|
|||||||
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
|
/// 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",
|
||||||
|
|||||||
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)
|
.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());
|
||||||
|
|||||||
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
|
/// 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
190
src/ui.rs
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user