diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 9621323fadb..336fbd28a4c 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -295,6 +295,75 @@ fn apply_event_preserves_input_session() { assert_eq!(session.input_state, input_state); } +/// Builds an in-progress Claude session with an optional starting cwd. Used by +/// the cwd-tracking tests below, which cover the `session_context.cwd` behavior +/// that `TerminalView::sync_block_pwd_from_cli_agent` reads to update the block +/// pwd (see GH #10031). +fn session_with_cwd(cwd: Option<&str>) -> CLIAgentSession { + CLIAgentSession { + agent: CLIAgent::Claude, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext { + cwd: cwd.map(str::to_string), + ..Default::default() + }, + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + remote_host: None, + plugin_version: None, + draft_text: None, + custom_command_prefix: None, + received_rich_notification: false, + } +} + +fn event_with_cwd(event: CLIAgentEventType, cwd: Option<&str>) -> CLIAgentEvent { + CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, + v: 1, + agent: CLIAgent::Claude, + event, + session_id: Some("abc".to_string()), + cwd: cwd.map(str::to_string), + project: None, + payload: CLIAgentEventPayload::default(), + } +} + +#[test] +fn apply_event_updates_cwd_from_event() { + let mut session = session_with_cwd(Some("/home/proj")); + session.apply_event(&event_with_cwd( + CLIAgentEventType::PromptSubmit, + Some("/home/proj/.claude/worktrees/feature"), + )); + assert_eq!( + session.session_context.cwd.as_deref(), + Some("/home/proj/.claude/worktrees/feature"), + ); +} + +#[test] +fn apply_event_preserves_cwd_when_event_has_none() { + let mut session = session_with_cwd(Some("/home/proj")); + // ToolComplete carries no cwd; the existing cwd must survive so the block + // doesn't lose its directory between reporting events. + session.status = CLIAgentSessionStatus::Blocked { message: None }; + session.apply_event(&event_with_cwd(CLIAgentEventType::ToolComplete, None)); + assert_eq!(session.session_context.cwd.as_deref(), Some("/home/proj")); +} + +#[test] +fn apply_event_records_cwd_when_starting_empty() { + let mut session = session_with_cwd(None); + session.apply_event(&event_with_cwd( + CLIAgentEventType::SessionStart, + Some("/home/proj"), + )); + assert_eq!(session.session_context.cwd.as_deref(), Some("/home/proj")); +} + #[test] fn is_remote_returns_true_when_remote_host_is_set() { let session = CLIAgentSession { diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 2cbd35d3739..d6cee7c7f7e 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -2450,6 +2450,28 @@ impl TerminalModel { self.registered_session_ids.insert(session_id); } + /// Updates the active block's working directory, applying the SSH-block + /// guard and routing to the block list. Shared by the OSC 7 handler and by + /// callers outside the terminal parser (e.g. CLI-agent cwd reporting), so + /// the guard and routing live in one place. + pub fn set_active_block_working_directory(&mut self, path: String) { + // OSC 7 is honor-system: the parser only accepts payloads whose host + // matches our local hostname, but a wrapper SSH session streams the + // remote shell's bytes through this same Performer, so a remote box + // with a coincident hostname could still slip through. Drop the + // update entirely while we know we're inside an SSH-launching block. + if self.is_ssh_block() { + log::debug!("Ignoring CWD update inside SSH session: {path:?}"); + return; + } + // Always route to the block list, not through `delegate!` — the + // alt-screen handler has no `set_current_working_directory` override, + // so a TUI program running on the alt screen (vim, htop, etc.) would + // silently swallow updates emitted by tools it launches. The CWD + // belongs on the block list regardless of what's currently rendered. + self.block_list.set_current_working_directory(path); + } + pub fn needs_bracketed_paste(&mut self) -> bool { delegate!(self.needs_bracketed_paste()) } @@ -2887,22 +2909,7 @@ impl ansi::Handler for TerminalModel { } fn set_current_working_directory(&mut self, path: String) { - // OSC 7 is honor-system: the parser only accepts payloads whose host - // matches our local hostname, but a wrapper SSH session streams the - // remote shell's bytes through this same Performer, so a remote box - // with a coincident hostname could still slip through. Drop the - // update entirely while we know we're inside an SSH-launching block. - if self.is_ssh_block() { - log::debug!("Ignoring OSC 7 CWD update inside SSH session: {path:?}"); - return; - } - // Always route OSC 7 to the block list, not through `delegate!` — - // the alt-screen handler has no `set_current_working_directory` - // override, so a TUI program running on the alt screen (vim, htop, - // etc.) would silently swallow updates emitted by tools it - // launches. The shell's CWD belongs on the block list regardless - // of what's currently rendered on screen. - self.block_list.set_current_working_directory(path); + self.set_active_block_working_directory(path); } fn precmd_with_completion_metadata(&mut self, data: PrecmdValue) { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index b366240311b..6ba3726d4e7 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -13420,6 +13420,7 @@ impl TerminalView { | CLIAgentSessionsModelEvent::Ended { .. } ) { + self.sync_block_pwd_from_cli_agent(ctx); self.update_pane_configuration(ctx); ctx.notify(); } @@ -13523,6 +13524,36 @@ impl TerminalView { ); } + /// Mirrors a CLI agent's reported working directory onto the active block's + /// pwd. CLI agents (e.g. Claude Code via the claude-code-warp plugin) report + /// their cwd in the OSC 777 payload, which lands in + /// `CLIAgentSession::session_context.cwd` but never reaches the block. Without + /// this bridge, an agent that changes directory mid-session — most commonly + /// `claude --worktree`, which relocates into `/.claude/worktrees/…` — + /// leaves the block pinned to its launch directory, so the WorkingDirectory + /// chip, tab subtitle, git branch, and diff/PR chips all stay stale (#10031). + /// + /// Routes through `TerminalModel::set_active_block_working_directory`, the + /// same path OSC 7 uses: it emits `BlockWorkingDirectoryUpdated` and refreshes + /// the downstream chips. Reusing it also inherits the `is_ssh_block()` guard, + /// so an agent running inside an SSH-wrapped block won't clobber the remote + /// block's pwd. The OSC 7 hostname gate is intentionally *not* applied — the + /// agent cwd is already a parsed local path with no `file://host` component to + /// validate. We dedupe against the current pwd here before taking the lock. + fn sync_block_pwd_from_cli_agent(&self, ctx: &AppContext) { + let Some(cwd) = CLIAgentSessionsModel::as_ref(ctx) + .session(self.view_id) + .and_then(|session| session.session_context.cwd.clone()) + .filter(|cwd| !cwd.is_empty()) + else { + return; + }; + if self.pwd().as_deref() == Some(cwd.as_str()) { + return; + } + self.model.lock().set_active_block_working_directory(cwd); + } + /// Handles the initialization of a session within this terminal pane. /// /// This does not indicate that the session has bootstrapped, but only diff --git a/specs/GH10031/product.md b/specs/GH10031/product.md new file mode 100644 index 00000000000..c129c55deb4 --- /dev/null +++ b/specs/GH10031/product.md @@ -0,0 +1,76 @@ +# product.md — CLI agent working-directory changes update the block's directory metadata + +Issue: https://github.com/warpdotdev/warp/issues/10031 + +## Summary + +When a CLI agent (e.g. Claude Code via the `claude-code-warp` plugin) changes +its working directory mid-session, Warp does not update the block's displayed +directory metadata. The most common trigger is `claude --worktree`, which +relocates the agent into `/.claude/worktrees/` and checks out a +new branch — but the block's `WorkingDirectory` chip, tab subtitle, git branch +indicator, and diff/PR chips all continue to reflect the launch directory. + +The plugin already reports the agent's current directory to Warp on every event +(in the OSC 777 `warp://cli-agent` payload). Warp stores it on the CLI-agent +session but never propagates it to the block that owns the displayed directory. +This spec defines the behavior that closes that gap. + +## Goals / Non-goals + +In scope: + +- When a CLI-agent session reports a working directory that differs from the + block's current directory, the block's directory and everything derived from + it (working-directory chip, tab subtitle, git branch, git diff / PR chips) + update to reflect the reported directory. + +Out of scope: + +- Changing how the plugin reports its directory (no plugin change is required; + the `cwd` is already in the payload). +- Directory tracking for agents that do **not** report a `cwd` (nothing to + propagate). +- Remote / SSH sessions: an agent running inside an SSH-wrapped block must not + move that block's directory (see invariant 6). +- Reconstructing directory history for past blocks; only the block that owns + the active CLI-agent session is updated. + +## Testable behavior invariants + +1. **Happy path — directory change is reflected.** Given a CLI-agent session + whose block shows directory `A`, when the agent reports a new working + directory `B` (B ≠ A, B non-empty), the block's displayed working directory + becomes `B`. + +2. **Derived metadata follows.** After invariant 1 fires, the working-directory + chip, the tab subtitle, the git branch indicator, and the git diff / PR + chips recompute against `B` (i.e. they reflect `B`'s branch and changes, not + `A`'s). This is the observable fix for the `claude --worktree` report. + +3. **No-op when unchanged.** If the reported directory equals the block's + current directory, no update, event, or recomputation is triggered. + +4. **Empty directory is ignored.** If the reported directory is absent or an + empty string, the block's directory is left unchanged. + +5. **Update timing.** The directory update occurs on the CLI-agent session + events that already carry directory information — session start, prompt + submitted, and tool completed — so the displayed directory converges to the + agent's directory as the session progresses, without requiring the user to + run a shell command. + +6. **SSH blocks are protected.** If the CLI-agent session's block is an + SSH-launching block, an agent-reported directory does **not** change the + block's directory (the remote shell owns that block's directory; a + locally-reported path must not clobber it). This matches the existing + guard applied to shell-emitted OSC 7 directory updates. + +7. **Non-agent blocks unaffected.** Blocks with no CLI-agent session continue + to derive their directory solely from shell precmd / shell-emitted OSC 7, as + before. This change adds no new behavior to non-agent blocks. + +## Open questions + +None. The reporting mechanism already exists; this spec only defines +propagating the reported directory to the block. diff --git a/specs/GH10031/tech.md b/specs/GH10031/tech.md new file mode 100644 index 00000000000..78ef85813ac --- /dev/null +++ b/specs/GH10031/tech.md @@ -0,0 +1,148 @@ +# tech.md — CLI agent working-directory changes update the block's directory metadata + +Issue: https://github.com/warpdotdev/warp/issues/10031 +Product spec: `specs/GH10031/product.md` + +## Context + +There are two independent "current working directory" tracks in the terminal, +and the CLI-agent track never reaches the block that owns the displayed +directory. All file references are at HEAD on `master`. + +### Track 1 — shell / OSC 7 → block directory (works today) + +A shell (or a program it launches) reports its directory via an OSC 7 +`file://host/path` escape sequence in the PTY stream: + +- `app/src/terminal/model/ansi/mod.rs:787` — `osc_dispatch`, the single OSC + dispatch point. +- `app/src/terminal/model/ansi/mod.rs:839` — the OSC `7` arm; rejoins params, + calls `parse_osc_7_cwd`, then `self.handler.set_current_working_directory(path)`. +- `app/src/terminal/model/ansi/mod.rs:156` — `parse_osc_7_cwd` / `osc_7_host_is_local`: + the payload is honored only if its host matches the local hostname; empty host + and `localhost` are rejected. This gate exists because OSC 7 arrives as + untrusted bytes in the PTY stream. +- `app/src/terminal/model/terminal_model.rs:2889` — + `::set_current_working_directory`: early-returns + when `self.is_ssh_block()` (`terminal_model.rs:2428`), then routes to + `self.block_list.set_current_working_directory(path)` (deliberately bypassing + the `delegate!` alt-screen macro). +- `app/src/terminal/model/block.rs:3417` — `Block::set_current_working_directory`: + dedupes against the current `pwd`, sets `self.pwd`, and emits + `Event::BlockWorkingDirectoryUpdated`. +- `app/src/terminal/view.rs:12593` — the view consumes + `ModelEvent::BlockWorkingDirectoryUpdated`, calls `apply_block_metadata_update` + and refreshes the `WorkingDirectory` prompt chip. +- `app/src/terminal/view/tab_metadata.rs:16` — `display_working_directory` reads + **only** the `WorkingDirectory` prompt chip or the block `pwd()`. This is what + the tab subtitle, git-branch chip, and diff/PR chips derive from. + +### Track 2 — CLI-agent cwd (dead-ends today) + +- `claude-code-warp/plugins/warp/scripts/build-payload.sh:41,57` — the plugin + puts the hook's `cwd` into the OSC 777 `warp://cli-agent` JSON payload. It + never emits OSC 7. +- `app/src/terminal/cli_agent_sessions/event/mod.rs` — the payload is parsed into + `CLIAgentEvent.cwd`. +- `app/src/terminal/cli_agent_sessions/listener/mod.rs:185` — the per-terminal + listener holds `terminal_view_id` and forwards parsed events to + `CLIAgentSessionsModel::update_from_event(view_id, &event, ctx)`. +- `app/src/terminal/cli_agent_sessions/mod.rs:179` — `CLIAgentSession::apply_event` + stores the directory: `self.session_context.cwd = event.cwd.clone()...`. This + `session_context.cwd` field is never read by `display_working_directory` and + never propagated to `Block::pwd`. +- `app/src/terminal/cli_agent_sessions/mod.rs:414` — `update_from_event` emits + `CLIAgentSessionsModelEvent::StatusChanged` and/or `SessionUpdated` + (the latter on `SessionStart | PromptSubmit | ToolComplete`). +- `app/src/terminal/view.rs:13387` — `TerminalView::handle_cli_agent_sessions_event` + consumes those model events. It already gates on + `*terminal_view_id == self.view_id` and already locks the terminal model to + mutate the active block in this same handler + (`let mut model = self.model.lock(); model.block_list_mut().active_block_mut()`, + `view.rs:13398` / `13407`). + +Net: the agent's directory lives in `session_context.cwd` for the agent footer, +but the block's `pwd` (and thus all displayed directory metadata) only updates +from Track 1. + +## Proposed changes + +Bridge Track 2 into Track 1 so the agent-reported directory flows through the +exact mechanism OSC 7 already uses. No new event types, no new UI, no plugin +change. + +### 1. Extract a reusable directory setter on `TerminalModel` + +`set_current_working_directory` is currently an `ansi::Handler` trait method +(`terminal_model.rs:2889`), so it can only be called from the terminal parser +and requires the trait in scope. Extract its body into a public inherent method +and have the trait impl delegate: + +- New `TerminalModel::set_active_block_working_directory(&mut self, path: String)` + (inherent, `pub`) containing the existing `is_ssh_block()` guard and the + `block_list.set_current_working_directory(path)` routing. +- `::set_current_working_directory` becomes a + one-line delegate to it. + +This keeps the SSH guard and block routing in one place and lets non-parser +callers (the view) invoke it without importing the parser trait. Behavior for +OSC 7 is unchanged. + +### 2. Propagate the agent cwd from the view + +Add `TerminalView::sync_block_pwd_from_cli_agent(&self, ctx: &AppContext)`: + +- Read the reported directory from the model: + `CLIAgentSessionsModel::as_ref(ctx).session(self.view_id).and_then(|s| s.session_context.cwd.clone())`. +- Skip if absent or empty (invariant 4). +- Skip if it equals `self.pwd()` (invariant 3; `self.pwd()` reads the active + block metadata, `view.rs:23207`). +- Otherwise `self.model.lock().set_active_block_working_directory(cwd)`. + +Call it from `handle_cli_agent_sessions_event` inside the existing block that +already matches `Started | StatusChanged | SessionUpdated | Ended` and is gated +on `event.terminal_view_id() == self.view_id` (`view.rs:13414`), immediately +before `update_pane_configuration` / `ctx.notify()`. Those variants cover the +session start / prompt-submit / tool-complete events that carry the directory +(invariant 5). + +Reading `session_context.cwd` from the model rather than from the +`StatusChanged` payload is deliberate: `SessionStart` and non-blocked +`ToolComplete` return `None` from `apply_event` (no `StatusChanged`) but still +set `session_context.cwd` and emit `SessionUpdated`, so the model is the single +source that reflects every reporting event. + +### Guards / tradeoffs + +- **SSH (invariant 6):** routing through `set_active_block_working_directory` + inherits the `is_ssh_block()` early-return, so an agent inside an SSH-wrapped + block cannot move the remote block's directory. +- **Hostname gate:** intentionally *not* applied. `osc_7_host_is_local` + validates untrusted `file://host/path` bytes from the PTY; the agent cwd is + already a parsed local path with no host component. Routing it through + `parse_osc_7_cwd` would be incorrect. +- **Dedup:** `Block::set_current_working_directory` already dedupes, but the view + also checks against `self.pwd()` first to avoid taking the model lock on the + common no-change case. + +## Testing and validation + +- **Invariants 1–2 (integration):** extend the CLI-agent session tests + (`app/src/terminal/cli_agent_sessions/mod_tests.rs`) — feed a `warp://cli-agent` + event carrying a new `cwd`, then assert the block's `pwd` and + `display_working_directory` (`tab_metadata.rs:16`) reflect it. This mirrors the + existing OSC 7 assertion in `crates/integration/src/test.rs:3474` + (`test_osc7_updates_current_working_directory`), which already checks that + `display_working_directory` follows a block-pwd change. +- **Invariant 3 (no-op):** report the same cwd twice; assert only one + `BlockWorkingDirectoryUpdated` is emitted (dedup at `block.rs:3417`). +- **Invariant 4 (empty):** report an empty/absent cwd; assert `pwd` unchanged. +- **Invariant 5 (timing):** assert the update fires for `SessionStart`, + `PromptSubmit`, and `ToolComplete` (all emit `SessionUpdated` / + `StatusChanged`). +- **Invariant 6 (SSH):** on an SSH block, report a cwd; assert the block's + directory is unchanged (guard inherited from `set_active_block_working_directory`). +- **Manual (invariant 2 end-to-end):** in Warp with the `claude-code-warp` + plugin, run `claude --worktree `; confirm the tab subtitle, working- + directory chip, git branch, and diff/PR chips update to the worktree. Capture + before/after screenshots for the PR.