Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions app/src/terminal/cli_agent_sessions/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 23 additions & 16 deletions app/src/terminal/model/terminal_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13420,6 +13420,7 @@ impl TerminalView {
| CLIAgentSessionsModelEvent::Ended { .. }
)
{
self.sync_block_pwd_from_cli_agent(ctx);
self.update_pane_configuration(ctx);
ctx.notify();
}
Expand Down Expand Up @@ -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 `<project>/.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
Expand Down
76 changes: 76 additions & 0 deletions specs/GH10031/product.md
Original file line number Diff line number Diff line change
@@ -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 `<project>/.claude/worktrees/<name>` 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.
148 changes: 148 additions & 0 deletions specs/GH10031/tech.md
Original file line number Diff line number Diff line change
@@ -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` —
`<TerminalModel as ansi::Handler>::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.
- `<TerminalModel as ansi::Handler>::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 <name>`; confirm the tab subtitle, working-
directory chip, git branch, and diff/PR chips update to the worktree. Capture
before/after screenshots for the PR.