diff --git a/docs/ssh-split-pane-inherit-cwd.md b/docs/ssh-split-pane-inherit-cwd.md new file mode 100644 index 00000000000..926962a65b0 --- /dev/null +++ b/docs/ssh-split-pane-inherit-cwd.md @@ -0,0 +1,176 @@ +# Split-Pane CWD Inheritance (Cmd+D / Cmd+Shift+D) + +## Problem + +When the user presses **Cmd+D** or **Cmd+Shift+D** to split a terminal pane, the new pane spawns at the worktree root instead of the source pane's current working directory. Most visible on **SSH** workspaces, but true locally as well — local only appears to "work" when the user hasn't `cd`'d away from the worktree root. + +Reference point: Ghostty, iTerm2, kitty, Warp, and WezTerm all inherit the source pane's live CWD when splitting. + +## Current behavior (code-level trace) + +1. `src/renderer/src/components/terminal-pane/keyboard-handlers.ts:283` dispatches on `action.type === 'splitActivePane'`; the `manager.splitPane(pane.id, direction)` call is at `:296`. +2. `src/renderer/src/lib/pane-manager/pane-manager.ts:93 splitPane()` creates the new pane and fires `onPaneCreated(newPane)`. +3. `src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts:349 onPaneCreated` → `connectPanePty(pane, manager, {...ptyDeps})`. +4. `src/renderer/src/components/terminal-pane/pty-connection.ts:272` builds the transport with `cwd: deps.cwd`. +5. `deps.cwd` is set once per tab at mount from the `TerminalPane` prop, always `worktree.path`. + +There is **no plumbing** anywhere in the renderer that (a) tracks the live CWD of a pane or (b) threads a per-split CWD override into `splitPane`. + +The renderer does not currently register any OSC-7 handler. Only the headless daemon emulator (`src/main/daemon/headless-emulator.ts`) parses OSC-7 — that path only feeds daemon snapshot metadata, never the renderer's pane state. + +## Prior art: Superset + +Superset (sibling project at `../../superset`) already implements renderer-side OSC-7 tracking: + +- `parseCwd.ts` — regex `ESC]7;file://[^/]*(/...)BEL|ST`, URI-decodes the path, returns the most recent match in a data chunk. +- `useTerminalCwd.ts` — per-pane hook: seeds from `initialCwd`/`workspaceCwd`, calls `parseCwd` on every xterm data event, stores `{terminalCwd, cwdConfirmed}` with a 150ms debounce. + +Superset's split action accepts `options.initialCwd` but its Cmd+D wiring passes a **preset** CWD, not the source pane's tracked `terminalCwd`. We will close that loop. + +## Design + +### Overview + +Track each pane's live CWD in the renderer via OSC-7, fall back to a one-shot `pty.getCwd` query, and pass the resolved value as `cwd` when spawning the new split PTY. + +### 1. Track per-pane CWD via OSC-7 + +Add an OSC-7 handler alongside the existing OSC-52 handler in `use-terminal-pane-lifecycle.ts` `onPaneCreated` (near line 369): + +```ts +const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => { + const cwd = parseOsc7(data) + if (cwd) { + paneCwdRef.current.set(pane.id, cwd) + } + // Return true so xterm marks the sequence handled and does not fall + // through to any builtin behavior. Note: xterm's `registerOscHandler` + // contract is "first handler that returns true wins"; if a future + // consumer (e.g. a shared daemon emulator path) also registers on + // code 7, registration order decides who sees each sequence. + return true +}) +``` + +- New ref on `TerminalPane`: `paneCwdRef = useRef>(new Map())`. +- Disposable tracked in an `osc7DisposablesRef` map parallel to `osc52DisposablesRef`, torn down in `onPaneClosed` (see `use-terminal-pane-lifecycle.ts:481-484` for the OSC-52 pattern). `onPaneClosed` must also `paneCwdRef.current.delete(paneId)` so the map doesn't accumulate dead entries across splits/closes. +- **Why `registerOscHandler(7)` rather than scanning raw data:** xterm's OSC parser handles BEL/ST termination, sequence fragmentation across chunks, and nesting. A regex on raw data (as Superset does) misses sequences split across PTY read boundaries. +- **Handler install must stay before PTY attach.** The current `onPaneCreated` body (`use-terminal-pane-lifecycle.ts:349-460`) installs OSC parser handlers synchronously *before* `connectPanePty`. That ordering is load-bearing: it guarantees the first byte of the PTY stream — which in the cold-restore path is replayed scrollback containing OSC-7 — reaches our handler. Add a `// Why:` comment so a future refactor does not move the install after attach. +- **Replay + stale OSC-7.** Replayed scrollback can contain OSC-7 from an earlier `cd` that no longer reflects the live shell cwd. `paneCwdRef` does not distinguish replayed vs. live entries. Store `{cwd, confirmed: boolean}` and mark `confirmed = false` when `isPaneReplaying(...)` is true at handler fire time, `confirmed = true` otherwise. `resolveSplitCwd` prefers a `confirmed` entry and otherwise falls through to `pty.getCwd`, which reflects live shell state via `/proc` or `lsof`. **Why the flag is reliable during replay:** `replayIntoTerminal` (`replay-guard.ts:37-55`) increments the counter *before* `pane.terminal.write(data, cb)` and decrements it inside xterm's write-completion callback. xterm fires parser handlers synchronously as it consumes the buffer, so every OSC-7 parsed out of the replayed chunk sees a non-zero counter. Do **not** change `replay-guard.ts` to a pre-`write` decrement — the whole design depends on the decrement being post-parse. + +No debouncing needed — `Map.set` is cheap and the value is read only on demand at split time. + +### 2. Resolve CWD at split time + +Introduce a helper `resolveSplitCwd(sourcePaneId, sourcePtyId, fallbackCwd): Promise`. Owned by `use-terminal-pane-lifecycle.ts` (co-located with `paneCwdRef`) and passed down to keyboard and context-menu handlers as a dep, symmetric with the existing `paneTransportsRef` wiring. + +1. If `paneCwdRef.current.get(sourcePaneId)` has `confirmed === true`, return its `cwd` **synchronously** (no await). OSC-7 from a live shell is authoritative and instant. +2. Otherwise `await window.api.pty.getCwd(sourcePtyId)` with a ~200ms soft timeout enforced renderer-side via `Promise.race` — keeps the "fall through to fallback" semantics in one place rather than threading timeouts into the provider. Use the result if non-empty. +3. Otherwise, if `paneCwdRef.current.get(sourcePaneId)` has `confirmed === false` (replayed OSC-7 only), return its `cwd` as a last-ditch guess before falling back. +4. Otherwise return `fallbackCwd` (the existing worktree root). + +**Sync vs async in the keydown handler.** The current Cmd+D handler at `keyboard-handlers.ts:283` is synchronous and calls `e.preventDefault()` / `e.stopImmediatePropagation()` before `splitPane`. The new flow preserves sync ordering: + +- Compute `preventDefault` + pane snapshot synchronously. +- Read `paneCwdRef` synchronously. If hit → call `splitPane` immediately (no `await`). +- If miss → fire-and-forget an `async` IIFE that awaits `pty.getCwd` then calls `splitPane`. This is equivalent to today's async pane-creation lifecycle, which is already non-blocking. + +Double Cmd+D in the cache-miss window: the second keypress fires before the first fire-and-forget IIFE has called `splitPane`, so both async callbacks still see the original source pane as active, resolve the same `pty.getCwd`, and produce two splits off that source (not a chain). This differs from today's fully-sync behavior, which would have chained the second split off the just-created pane. Accepted tradeoff — the window is ~1 IPC round-trip (≤200 ms) and "both splits inherit the same cwd" is not wrong, just not chained. Cache-hit (common case: an active shell with OSC-7 already seen) is still sync and preserves chaining. + +### 3. Thread CWD through splitPane + +`PaneManager.splitPane(paneId, direction, opts?)` currently accepts `opts: { ratio?: number }` (`pane-manager.ts:93-97`). Extend to `{ ratio?: number; cwd?: string }`. + +Plumb the hint to `onPaneCreated` by widening its signature to `(pane, spawnHints?: { cwd?: string })`. The hint is forwarded synchronously inside `splitPane` and has no reason to outlive that call. The existing `pendingSplitScrollState` field on `ManagedPaneInternal` exists only because rAFs read it *later*; that is not the case here. + +**Typed API break — all call sites of `onPaneCreated` must update:** + +- `PaneManagerOptions.onPaneCreated` type declaration in `pane-manager.ts`. +- The `createPane` invocation at `pane-manager.ts:89` passes `undefined` for `spawnHints` (new panes aren't splits). +- The `splitPane` invocation at `pane-manager.ts:133` forwards `opts?.cwd` as `{ cwd: opts.cwd }` when set. + +In `use-terminal-pane-lifecycle.ts`: + +```ts +onPaneCreated: (pane, spawnHints) => { + // ...existing handler install... + const panePtyBinding = connectPanePty(pane, manager, { + ...ptyDeps, + ...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}), + restoredLeafId + }) +} +``` + +Spread order matters: `spawnHints.cwd` overrides the tab-level `ptyDeps.cwd`. + +**Callers of `splitPane` — which inherit, which don't.** + +| Call site | File:line | Passes `cwd`? | Why | +|---|---|---|---| +| Cmd+D / Cmd+Shift+D | `keyboard-handlers.ts:296` | **yes** | primary feature | +| Context menu: Split Right / Split Down | `use-terminal-pane-context-menu.ts:114,121` | **yes** | same user intent as Cmd+D | + +Both Cmd+D and context-menu call sites receive a new dep `resolveSplitCwd: (paneId, ptyId, fallbackCwd) => Promise` and replace `manager.splitPane(pane.id, dir)` with roughly: + +```ts +const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null +const cached = paneCwdRef.current.get(pane.id) +if (cached?.confirmed) { + manager.splitPane(pane.id, dir, { cwd: cached.cwd }) + return +} +void (async () => { + const cwd = await resolveSplitCwd(pane.id, ptyId, fallbackCwd) + managerRef.current?.splitPane(pane.id, dir, { cwd }) +})() +``` + +The keydown handler still calls `e.preventDefault()` / `e.stopImmediatePropagation()` synchronously before dispatching the async resolution — matches existing async pane-creation lifecycle. +| Worktree setup split | `use-terminal-pane-lifecycle.ts:693` | no | bootstrap command runs at worktree root by contract | +| Issue-command split | `use-terminal-pane-lifecycle.ts:719` | no | per-repo automation; runs at worktree root by contract | +| CLI-triggered split (`onCliSplitPane`) | `use-terminal-pane-lifecycle.ts:754,757` | no | CLI protocol today carries only `paneRuntimeId` + `direction`; no cwd field. Preserve today's behavior until the CLI protocol adds one. | +| Layout restore | `layout-serialization.ts:299` | no | restored panes re-emit their own OSC-7 on first prompt | + +The setup/issue/CLI sites go through `splitPaneWithOneShotStartup`, which is a wrapper receiving a `() => manager.splitPane(...)` thunk. Widening the `splitPane` opts is backward-compatible; the wrapper is unaffected because the new `spawnHints` is a parameter of the `onPaneCreated` *callback*, not of `splitPane` itself. + +`pendingSpawnByPaneKey` (`pty-connection.ts:21`) keys by pane key; each new split gets a fresh pane id, so no collision between the inherited-cwd path and existing dedup. + +### 4. Preload / main bridge for `pty.getCwd` + +`SshPtyProvider.getCwd` already exists (`ssh-pty-provider.ts:132`) and delegates to the relay which calls `resolveProcessCwd(pid, initialCwd)` via `/proc//cwd` or `lsof`. Changes needed: + +- **`LocalPtyProvider.getCwd` (`src/main/providers/local-pty-provider.ts:388`) currently `throw`s when the id is unknown and returns `''` otherwise.** Change: return `''` on unknown id (do not throw), and on a known id call `resolveProcessCwd(proc.pid, '')` where `proc = ptyProcesses.get(id)`. Pass `''` as the fallback — **not** the pane's initial cwd — because the renderer uses empty-string to mean "no result, try the next fallback layer"; returning the initial cwd would make the renderer think it had an answer and skip its own fallback chain. Do **not** throw on unknown id: the caller treats that case as non-exceptional. +- **Code sharing for `resolveProcessCwd`.** The helper currently lives in `src/relay/pty-shell-utils.ts` and is bundled into the relay binary. The Electron main process and the relay have separate build graphs; having main import from `src/relay/` (or vice-versa) is not a pattern this repo uses. Rather than force a `src/shared/` split (and touch the relay build), keep the relay's copy in place and add a tiny duplicate in `src/main/providers/process-cwd.ts` for `LocalPtyProvider`. The function is ~30 lines and has no shared state; the cost of duplication is lower than the cost of reshaping two bundle graphs. +- **IPC handler:** add `ipcMain.handle('pty:getCwd', (_e, {id}) => getProviderForPty(id).getCwd(id))` in `src/main/ipc/pty.ts`, and add `ipcMain.removeHandler('pty:getCwd')` to the removeHandler block at `pty.ts:325-331` (next to the existing `pty:hasChildProcesses` / `pty:getForegroundProcess` entries). +- **Preload:** add `pty.getCwd(id: string): Promise` in `src/preload/index.ts` alongside `hasChildProcesses`/`getForegroundProcess`, and mirror in `src/preload/api-types.ts`. Returns `''` when unknown. + +### 5. Edge cases + +- **Agent TUIs (Claude Code, Codex, cursor-agent).** No OSC-7 because no shell prompt. `paneCwdRef` stays empty; `pty.getCwd` via `/proc//cwd` still resolves against the running agent process. +- **Just-split panes (no OSC-7 yet).** A fresh pane before its first prompt has no entry. `pty.getCwd` fallback handles it. Double-Cmd+D inherits from the still-fresh shell's spawn CWD — correct. +- **SSH reconnect / cold-restore.** OSC-7 entries are keyed by ephemeral `paneId`. Replayed OSC-7 in the scrollback re-feeds the handler via `terminal.write` (`replay-guard.ts:47`) and repopulates the map with `confirmed: false`. The first live OSC-7 after the first prompt upgrades it to `confirmed: true`. If the user hits Cmd+D before the first live prompt, `resolveSplitCwd` takes the `pty.getCwd` path (which queries `/proc//cwd` on the live shell) and only falls back to the replayed entry if IPC yields nothing. +- **Non-emitting shells.** Minimal `sh` without OSC-7 → `pty.getCwd` fallback carries it. Explicit reason to keep the two-layer strategy. +- **Windows.** `/proc` is absent and `lsof` isn't native. On Windows, `LocalPtyProvider.getCwd` must return `''` (fall through to worktree root) and must **not** throw — after the §4 change, the existing `throw`-on-unknown-id is replaced with `''`, and the Windows branch simply returns `''` unconditionally. Not a regression over today's behavior. OSC-7 from PowerShell/pwsh still works because parsing is renderer-side and platform-agnostic. +- **Expanded-pane mode.** `keyboard-handlers.ts:286` exits expanded mode before splitting. CWD lookup runs on the currently-active pane before that exit, which is the correct source. + +### 6. Tests + +- Unit: `parseOsc7` — BEL vs ST termination, percent-decoded spaces, empty host, Windows drive-letter URIs (`file:///C:/Users/...`). On Windows, `parseOsc7` must return a string that `node-pty` accepts as `cwd` on `spawn` (backslash- or forward-slash-separated, with drive letter, no leading `/`). Normalize inside `parseOsc7` via `path.win32.normalize` when `process.platform === 'win32'` and assert the test output equals what `spawn`'s `cwd` option tolerates — don't leave the choice implicit. +- Unit: `resolveSplitCwd` priority — OSC-7 cache hit skips IPC; cache miss queries IPC; empty IPC result falls through to `deps.cwd`; IPC timeout falls through to `deps.cwd`. +- Unit: `LocalPtyProvider.getCwd` returns `''` (not throws) for unknown ids. +- Integration (`tests/e2e/terminal-panes.spec.ts`): `cd /tmp && emit OSC-7; Cmd+D → new pane pwd === /tmp`. +- No SSH e2e suite exists in `tests/e2e/` today; skip. + +### 7. Rollout / compatibility + +- No migration or settings surface. +- Safe default: if OSC-7 and `pty.getCwd` both fail, fall back to worktree root. Users never end up worse off. +- Ships without a flag. + +## Out of scope + +- Persisting per-pane CWD across app restarts. Each session starts fresh; OSC-7 re-arrives on first prompt. +- **New-tab-from-pane inheritance (Cmd+T).** Deliberately deferred — users will ask why Cmd+D inherits and Cmd+T does not, but new-tab also implies worktree choice and startup payload semantics that are not in scope here. Reuse `paneCwdRef` + `resolveSplitCwd` when the new-tab flow is revisited. +- Using tracked CWD for status-bar display or any other non-split consumer. Separate features that can reuse `paneCwdRef` later. +- CWD for context-menu entries that open external tools (file manager, editor). Those already have their own resolution paths. diff --git a/orca-before-split.png b/orca-before-split.png new file mode 100644 index 00000000000..fb23d337539 Binary files /dev/null and b/orca-before-split.png differ diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index b694c4cf562..28bf56c62ee 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -273,7 +273,7 @@ export class DaemonServer { return {} case 'getCwd': - return { cwd: this.host.getCwd(request.payload.sessionId) } + return { cwd: await this.host.getCwd(request.payload.sessionId) } case 'clearScrollback': this.host.clearScrollback(request.payload.sessionId) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index bded2ef29db..c344855b238 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -155,8 +155,20 @@ export class TerminalHost { session?.detachClient(token) } - getCwd(sessionId: string): string | null { - return this.getAliveSession(sessionId).getCwd() + async getCwd(sessionId: string): Promise { + const session = this.getAliveSession(sessionId) + const tracked = session.getCwd() + if (tracked) { + return tracked + } + // Why: the emulator's cwd is null until the shell emits OSC 7. Orca's + // bash/zsh rcfiles ship with OSC 133 markers but not OSC 7, so the + // tracked value stays null through the entire session for most users. + // Fall back to the live process cwd via /proc//cwd (Linux) or + // lsof (macOS). Matches the LocalPtyProvider.getCwd fallback. + const { resolveProcessCwd } = await import('../providers/process-cwd') + const resolved = await resolveProcessCwd(session.pid, '') + return resolved || null } clearScrollback(sessionId: string): void { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 0945d9d72b1..3a473661d17 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -334,6 +334,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:listSessions') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:getCwd') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') @@ -819,6 +820,20 @@ export function registerPtyHandlers( return getProviderForPty(args.id).getForegroundProcess(args.id) } ) + + // Why: renderer needs the live shell cwd when the user presses Cmd+D so + // the new split pane inherits the source pane's cwd instead of the + // worktree root. Routed through getProviderForPty so local and SSH PTYs + // use the same code path. Providers return '' when the id is unknown or + // the platform cannot resolve a cwd (Windows); the renderer treats '' + // as "fall through to the next fallback layer". + ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { + try { + return await getProviderForPty(args.id).getCwd(args.id) + } catch { + return '' + } + }) } /** diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0ccd6c1e489..8ce635fdfe7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -386,11 +386,19 @@ export class LocalPtyProvider implements IPtyProvider { } async getCwd(id: string): Promise { - if (!ptyProcesses.has(id)) { - throw new Error(`PTY ${id} not found`) + const proc = ptyProcesses.get(id) + // Why: return '' (not throw) on unknown id — the renderer treats empty + // as "no result, try the next fallback layer". Throwing would surface a + // noisy rejection for a non-exceptional case (PTY just exited, pane + // still has its old id). + if (!proc) { + return '' } - // node-pty doesn't expose cwd; would need /proc on Linux or lsof on macOS - return '' + // Why: pass '' (not an initialCwd) as fallback so an unresolved cwd + // stays empty. Handing back a fabricated initialCwd would lie to the + // renderer and short-circuit its own fallback chain. + const { resolveProcessCwd } = await import('./process-cwd') + return resolveProcessCwd(proc.pid, '') } async getInitialCwd(_id: string): Promise { return '' diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts new file mode 100644 index 00000000000..b1b0c9f41a2 --- /dev/null +++ b/src/main/providers/process-cwd.ts @@ -0,0 +1,48 @@ +import { execFile as execFileCb } from 'child_process' +import { existsSync } from 'fs' +import { promisify } from 'util' + +const execFile = promisify(execFileCb) + +/** + * Resolve the current working directory of a local process by pid. + * + * Why duplicated from `src/relay/pty-shell-utils.ts`: the relay and Electron + * main process have separate build graphs, and cross-importing across them + * is not a pattern used in this repo. The function is short and pure, and + * the duplication is cheaper than reshaping both bundle graphs. + * + * Tries `/proc//cwd` on Linux, falls back to `lsof -d cwd` on macOS. + * Returns `fallbackCwd` when neither works (including Windows, where + * `/proc` is absent and `lsof` is not native). + */ +export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promise { + const procCwd = `/proc/${pid}/cwd` + if (existsSync(procCwd)) { + try { + const { readlinkSync } = await import('fs') + return readlinkSync(procCwd) + } catch { + /* fall through */ + } + } + + try { + const { stdout } = await execFile('lsof', ['-p', String(pid), '-d', 'cwd', '-Fn'], { + encoding: 'utf-8', + timeout: 3000 + }) + for (const line of stdout.split('\n')) { + if (line.startsWith('n') && line.includes('/')) { + const candidate = line.slice(1) + if (existsSync(candidate)) { + return candidate + } + } + } + } catch { + /* fall through */ + } + + return fallbackCwd +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fb0e79a771d..b6ccff97b76 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -390,6 +390,7 @@ export type PreloadApi = { ackColdRestore: (id: string) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise + getCwd: (id: string) => Promise listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> onData: (callback: (data: { id: string; data: string }) => void) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index b249f4fe984..f211369fb06 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -339,6 +339,10 @@ const api = { getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }), + /** Resolve the live cwd of a PTY via `/proc` (Linux) or `lsof` (macOS). + * Returns `''` when the id is unknown or the platform cannot resolve one. */ + getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), + onData: (callback: (data: { id: string; data: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => callback(data) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index dae5693a50e..afd9b461e67 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -63,6 +63,11 @@ export default function TerminalPane({ new Map() ) const paneTransportsRef = useRef>(new Map()) + // Why: per-pane live cwd tracked via OSC 7 for split-pane cwd inheritance. + // See docs/ssh-split-pane-inherit-cwd.md. The OSC 7 handler is installed + // in use-terminal-pane-lifecycle; keyboard and context-menu split actions + // read this map at dispatch time to pass cwd into splitPane. + const paneCwdRef = useRef>(new Map()) const paneMode2031Ref = useRef>(new Map()) const paneLastThemeModeRef = useRef>(new Map()) const panePtyBindingsRef = useRef>(new Map()) @@ -373,6 +378,7 @@ export default function TerminalPane({ expandedStyleSnapshotRef, paneFontSizesRef, paneTransportsRef, + paneCwdRef, paneMode2031Ref, paneLastThemeModeRef, panePtyBindingsRef, @@ -513,6 +519,8 @@ export default function TerminalPane({ isActive, managerRef, paneTransportsRef, + paneCwdRef, + fallbackCwd: cwd ?? '', expandedPaneIdRef, setExpandedPane, restoreExpandedLayout, @@ -865,6 +873,9 @@ export default function TerminalPane({ const contextMenu = useTerminalPaneContextMenu({ managerRef, + paneTransportsRef, + paneCwdRef, + fallbackCwd: cwd ?? '', toggleExpandPane, onRequestClosePane: handleRequestClosePane, onSetTitle: handleStartRename, diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 46bd21574c1..fe603575796 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -3,6 +3,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import { resolveTerminalShortcutAction } from './terminal-shortcut-policy' import type { MacOptionAsAlt } from './terminal-shortcut-policy' +import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { @@ -66,6 +67,9 @@ type KeyboardHandlersDeps = { isActive: boolean managerRef: React.RefObject paneTransportsRef: React.RefObject> + paneCwdRef: React.RefObject + /** Worktree-root cwd used when OSC 7 and pty.getCwd both fail. */ + fallbackCwd: string expandedPaneIdRef: React.RefObject setExpandedPane: (paneId: number | null) => void restoreExpandedLayout: () => void @@ -83,6 +87,8 @@ export function useTerminalKeyboardShortcuts({ isActive, managerRef, paneTransportsRef, + paneCwdRef, + fallbackCwd, expandedPaneIdRef, setExpandedPane, restoreExpandedLayout, @@ -293,7 +299,27 @@ export function useTerminalKeyboardShortcuts({ if (!pane) { return } - manager.splitPane(pane.id, action.direction) + // Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md): + // if we have a confirmed live OSC 7 for the source pane, split + // synchronously to preserve chaining on rapid Cmd+D. Otherwise fall + // back to an async resolve that queries pty.getCwd. + const cached = paneCwdRef.current.get(pane.id) + if (cached?.confirmed && cached.cwd) { + manager.splitPane(pane.id, action.direction, { cwd: cached.cwd }) + return + } + const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null + const paneIdAtDispatch = pane.id + const directionAtDispatch = action.direction + void (async () => { + const cwd = await resolveSplitCwd({ + paneCwdMap: paneCwdRef.current, + sourcePaneId: paneIdAtDispatch, + sourcePtyId: ptyId, + fallbackCwd + }) + managerRef.current?.splitPane(paneIdAtDispatch, directionAtDispatch, { cwd }) + })() } } @@ -309,6 +335,8 @@ export function useTerminalKeyboardShortcuts({ isActive, managerRef, paneTransportsRef, + paneCwdRef, + fallbackCwd, expandedPaneIdRef, setExpandedPane, restoreExpandedLayout, diff --git a/src/renderer/src/components/terminal-pane/parse-osc7.test.ts b/src/renderer/src/components/terminal-pane/parse-osc7.test.ts new file mode 100644 index 00000000000..0a2f3f8260c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parse-osc7.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { parseOsc7 } from './parse-osc7' + +describe('parseOsc7', () => { + it('extracts a plain POSIX path', () => { + expect(parseOsc7('file://host/home/jin/repo')).toBe('/home/jin/repo') + }) + + it('accepts an empty host', () => { + expect(parseOsc7('file:///home/jin')).toBe('/home/jin') + }) + + it('percent-decodes spaces and unicode', () => { + expect(parseOsc7('file:///home/jin/my%20code')).toBe('/home/jin/my code') + }) + + it('strips the leading slash before a Windows drive letter', () => { + expect(parseOsc7('file:///C:/Users/jin/repo')).toBe('C:/Users/jin/repo') + }) + + it('returns null for non-file URIs', () => { + expect(parseOsc7('http://example.com/')).toBeNull() + }) + + it('returns null for unterminated/malformed input', () => { + expect(parseOsc7('not a uri')).toBeNull() + expect(parseOsc7('file://host')).toBeNull() + }) + + it('returns null for invalid percent-encoding', () => { + expect(parseOsc7('file:///bad%ZZ')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parse-osc7.ts b/src/renderer/src/components/terminal-pane/parse-osc7.ts new file mode 100644 index 00000000000..c50ca0d6023 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parse-osc7.ts @@ -0,0 +1,40 @@ +// OSC 7 — "report current working directory". Shells (bash/zsh, plus the +// typical PROMPT_COMMAND / precmd one-liner) emit +// +// \x1b]7;file:///\x07 (or ST terminator) +// +// on every prompt so terminal emulators can track the live cwd. xterm's +// parser strips the leading `\x1b]7;` and trailing BEL/ST before handing us +// the payload, so `data` here is just the `file://...` URI. + +const OSC7_URI = /^file:\/\/[^/]*(\/.*)$/ + +/** + * Parse an OSC 7 payload and return the decoded path, or null if the payload + * is not a file URI we recognize. Returns the path a `cwd` option of + * `child_process.spawn` / `node-pty` would accept on the current platform. + */ +export function parseOsc7(data: string): string | null { + const match = OSC7_URI.exec(data) + if (!match) { + return null + } + let path: string + try { + path = decodeURIComponent(match[1]) + } catch { + return null + } + if (!path) { + return null + } + // Why: on Windows the URI looks like file:///C:/Users/... — match[1] is + // `/C:/Users/...`, which `spawn`'s cwd option does not accept. Strip the + // leading slash before the drive letter so we hand back `C:/Users/...`. + // Renderer runs with sandbox + contextIsolation so process.platform is + // unavailable; detect Windows paths by shape instead. + if (/^\/[A-Za-z]:/.test(path)) { + path = path.slice(1) + } + return path +} diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts new file mode 100644 index 00000000000..9f4f5114b0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' + +function installGetCwd(fn: (id: string) => Promise): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shim for window.api.pty.getCwd + ;(globalThis as any).window = { api: { pty: { getCwd: fn } } } +} + +describe('resolveSplitCwd', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('returns the confirmed OSC 7 entry without calling IPC', async () => { + const getCwd = vi.fn() + installGetCwd(getCwd as unknown as (id: string) => Promise) + const paneCwdMap: PaneCwdMap = new Map([[1, { cwd: '/live/here', confirmed: true }]]) + const result = await resolveSplitCwd({ + paneCwdMap, + sourcePaneId: 1, + sourcePtyId: 'pty-1', + fallbackCwd: '/worktree' + }) + expect(result).toBe('/live/here') + expect(getCwd).not.toHaveBeenCalled() + }) + + it('queries IPC when no confirmed entry exists', async () => { + installGetCwd(async () => '/tmp/ipc') + const paneCwdMap: PaneCwdMap = new Map() + const result = await resolveSplitCwd({ + paneCwdMap, + sourcePaneId: 1, + sourcePtyId: 'pty-1', + fallbackCwd: '/worktree' + }) + expect(result).toBe('/tmp/ipc') + }) + + it('falls through to the unconfirmed cached entry when IPC returns empty', async () => { + installGetCwd(async () => '') + const paneCwdMap: PaneCwdMap = new Map([[1, { cwd: '/replayed', confirmed: false }]]) + const result = await resolveSplitCwd({ + paneCwdMap, + sourcePaneId: 1, + sourcePtyId: 'pty-1', + fallbackCwd: '/worktree' + }) + expect(result).toBe('/replayed') + }) + + it('falls back to worktree root when OSC 7 and IPC both miss', async () => { + installGetCwd(async () => '') + const result = await resolveSplitCwd({ + paneCwdMap: new Map(), + sourcePaneId: 1, + sourcePtyId: 'pty-1', + fallbackCwd: '/worktree' + }) + expect(result).toBe('/worktree') + }) + + it('times out and falls back when IPC hangs', async () => { + installGetCwd(() => new Promise(() => {})) + const promise = resolveSplitCwd({ + paneCwdMap: new Map(), + sourcePaneId: 1, + sourcePtyId: 'pty-1', + fallbackCwd: '/worktree' + }) + await vi.advanceTimersByTimeAsync(250) + expect(await promise).toBe('/worktree') + }) + + it('skips IPC entirely when there is no source PTY id', async () => { + const getCwd = vi.fn() + installGetCwd(getCwd as unknown as (id: string) => Promise) + const result = await resolveSplitCwd({ + paneCwdMap: new Map(), + sourcePaneId: 1, + sourcePtyId: null, + fallbackCwd: '/worktree' + }) + expect(result).toBe('/worktree') + expect(getCwd).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts new file mode 100644 index 00000000000..79ca9d25238 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts @@ -0,0 +1,49 @@ +// Why: resolving the "where should the new split start?" question is a +// two-layer strategy — OSC 7 from the live shell (fast, authoritative) with a +// `/proc`-or-lsof-backed IPC fallback for shells that never emit OSC 7 (agent +// TUIs, minimal sh). Both layers can legitimately come back empty, so the +// helper always finishes by returning the caller's worktree-root fallback. + +export type PaneCwdEntry = { cwd: string; confirmed: boolean } + +export type PaneCwdMap = Map + +const GET_CWD_TIMEOUT_MS = 200 + +export async function resolveSplitCwd(args: { + paneCwdMap: PaneCwdMap + sourcePaneId: number + sourcePtyId: string | null + fallbackCwd: string +}): Promise { + const { paneCwdMap, sourcePaneId, sourcePtyId, fallbackCwd } = args + + // 1) Live OSC 7 wins — no IPC round-trip needed. + const cached = paneCwdMap.get(sourcePaneId) + if (cached?.confirmed && cached.cwd) { + return cached.cwd + } + + // 2) Ask the PTY provider (/proc or lsof). Enforce a soft timeout + // renderer-side so a slow SSH relay can't stall the split. + if (sourcePtyId) { + try { + const ipcCwd = await Promise.race([ + window.api.pty.getCwd(sourcePtyId).catch(() => null), + new Promise((resolve) => setTimeout(() => resolve(null), GET_CWD_TIMEOUT_MS)) + ]) + if (ipcCwd) { + return ipcCwd + } + } catch { + /* fall through */ + } + } + + // 3) Last-ditch: replayed OSC 7 that we couldn't confirm as live. + if (cached?.cwd) { + return cached.cwd + } + + return fallbackCwd +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index c5dbc1befa0..4de61567f16 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -1,10 +1,15 @@ import { useEffect, useRef, useState } from 'react' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject + paneTransportsRef: React.RefObject> + paneCwdRef: React.RefObject + fallbackCwd: string toggleExpandPane: (paneId: number) => void onRequestClosePane: (paneId: number) => void onSetTitle: (paneId: number) => void @@ -31,6 +36,9 @@ type TerminalMenuState = { export function useTerminalPaneContextMenu({ managerRef, + paneTransportsRef, + paneCwdRef, + fallbackCwd, toggleExpandPane, onRequestClosePane, onSetTitle, @@ -108,19 +116,34 @@ export function useTerminalPaneContextMenu({ pane.terminal.focus() } - const onSplitRight = (): void => { + // Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md): + // mirror the Cmd+D path — sync split on confirmed OSC 7 cache hit, + // otherwise fall back to async resolveSplitCwd. + const splitWithInheritedCwd = (direction: 'vertical' | 'horizontal'): void => { const pane = resolveMenuPane() - if (pane) { - managerRef.current?.splitPane(pane.id, 'vertical') + if (!pane) { + return } + const cached = paneCwdRef.current.get(pane.id) + if (cached?.confirmed && cached.cwd) { + managerRef.current?.splitPane(pane.id, direction, { cwd: cached.cwd }) + return + } + const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null + const paneId = pane.id + void (async () => { + const cwd = await resolveSplitCwd({ + paneCwdMap: paneCwdRef.current, + sourcePaneId: paneId, + sourcePtyId: ptyId, + fallbackCwd + }) + managerRef.current?.splitPane(paneId, direction, { cwd }) + })() } - const onSplitDown = (): void => { - const pane = resolveMenuPane() - if (pane) { - managerRef.current?.splitPane(pane.id, 'horizontal') - } - } + const onSplitRight = (): void => splitWithInheritedCwd('vertical') + const onSplitDown = (): void => splitWithInheritedCwd('horizontal') const onClosePane = (): void => { const pane = resolveMenuPane() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index a06646ac3ab..5136ef26248 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -29,6 +29,8 @@ import { mode2031SequenceFor } from './terminal-appearance' import { parseOsc52 } from './osc52-clipboard' +import { parseOsc7 } from './parse-osc7' +import type { PaneCwdMap } from './resolve-split-cwd' import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' @@ -78,6 +80,10 @@ type UseTerminalPaneLifecycleDeps = { > paneFontSizesRef: React.RefObject> paneTransportsRef: React.RefObject> + /** Shared map of per-pane live cwd, populated by the OSC 7 handler + * installed in onPaneCreated. Exposed to TerminalPane so keyboard and + * context-menu split handlers can read it synchronously for cache hits. */ + paneCwdRef: React.RefObject paneMode2031Ref: React.RefObject> paneLastThemeModeRef: React.RefObject> panePtyBindingsRef: React.RefObject> @@ -165,6 +171,7 @@ export function useTerminalPaneLifecycle({ expandedStyleSnapshotRef, paneFontSizesRef, paneTransportsRef, + paneCwdRef, paneMode2031Ref, paneLastThemeModeRef, panePtyBindingsRef, @@ -205,6 +212,7 @@ export function useTerminalPaneLifecycle({ const selectionDisposablesRef = useRef(new Map()) const mode2031DisposablesRef = useRef(new Map()) const osc52DisposablesRef = useRef(new Map()) + const osc7DisposablesRef = useRef(new Map()) const mouseHideDisposablesRef = useRef(new Map()) const applyAppearance = (manager: PaneManager): void => { @@ -349,7 +357,10 @@ export function useTerminalPaneLifecycle({ const urlOpenLinkHint = getTerminalUrlOpenHint() const manager = new PaneManager(container, { - onPaneCreated: (pane) => { + // Why: `spawnHints` carries the resolved cwd from Cmd+D / context-menu + // Split actions so the new PTY inherits the source pane's live cwd. + // Split-pane CWD inheritance — see docs/ssh-split-pane-inherit-cwd.md. + onPaneCreated: (pane, spawnHints) => { // Install mode 2031 parser handlers before PTY attach so the child's // initial CSI ?2031h (sent at startup) is captured. const mode2031Disposables = installMode2031Handlers({ @@ -387,6 +398,32 @@ export function useTerminalPaneLifecycle({ }) osc52DisposablesRef.current.set(pane.id, osc52Disposable) + // OSC 7 — shell-reported current working directory. Drives split-pane + // cwd inheritance (Cmd+D / Cmd+Shift+D / context-menu Split). Handler + // install MUST remain before connectPanePty: the cold-restore path + // replays recorded PTY output into the terminal synchronously from the + // first PTY read, so a handler registered later would miss the first + // OSC 7 in replayed scrollback. + // + // Why the replay flag is reliable here: replayIntoTerminal increments + // a per-pane counter BEFORE xterm.write and decrements it in xterm's + // write-completion callback (replay-guard.ts). xterm parses OSC + // synchronously as it consumes the buffer, so every OSC 7 emitted + // during replay is seen with the counter non-zero. + // + // Return true so xterm marks the sequence handled. If a future + // consumer registers on code 7, registration order decides who sees + // each sequence. + const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => { + const cwd = parseOsc7(data) + if (cwd) { + const confirmed = !isPaneReplaying(replayingPanesRef, pane.id) + paneCwdRef.current.set(pane.id, { cwd, confirmed }) + } + return true + }) + osc7DisposablesRef.current.set(pane.id, osc7Disposable) + const linkProviderDisposable = pane.terminal.registerLinkProvider( createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint) ) @@ -442,6 +479,10 @@ export function useTerminalPaneLifecycle({ restoredPaneCreateIndex += 1 const panePtyBinding = connectPanePty(pane, manager, { ...ptyDeps, + // Why: spread order matters — spawnHints.cwd (inherited from the + // source pane) must override the tab-level ptyDeps.cwd (worktree + // root) so Cmd+D splits boot in the live cwd. + ...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}), restoredLeafId }) // Why: connectPanePty receives a spread copy of ptyDeps, so the @@ -486,6 +527,14 @@ export function useTerminalPaneLifecycle({ osc52Disposable.dispose() osc52DisposablesRef.current.delete(paneId) } + const osc7Disposable = osc7DisposablesRef.current.get(paneId) + if (osc7Disposable) { + osc7Disposable.dispose() + osc7DisposablesRef.current.delete(paneId) + } + // Why: drop the tracked cwd so the map doesn't accumulate dead + // entries across splits/closes over long sessions. + paneCwdRef.current.delete(paneId) const mouseHideDisposable = mouseHideDisposablesRef.current.get(paneId) if (mouseHideDisposable) { mouseHideDisposable.dispose() diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 39b00efd82b..84ce99705ff 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -12,8 +12,16 @@ import type { SerializeAddon } from '@xterm/addon-serialize' // Public interfaces // --------------------------------------------------------------------------- +/** Hints forwarded from splitPane() into onPaneCreated for a single split. + * Currently only carries the resolved cwd for the new pane's PTY spawn. + * Kept as a separate parameter (rather than extending ManagedPane) so the + * hint is scoped to pane creation and does not live on the pane afterwards. */ +export type PaneSpawnHints = { + cwd?: string +} + export type PaneManagerOptions = { - onPaneCreated?: (pane: ManagedPane) => void | Promise + onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise onPaneClosed?: (paneId: number) => void onActivePaneChange?: (pane: ManagedPane) => void onLayoutChanged?: () => void diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 7f358ec976a..bf70c770f1e 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -93,7 +93,7 @@ export class PaneManager { splitPane( paneId: number, direction: 'vertical' | 'horizontal', - opts?: { ratio?: number } + opts?: { ratio?: number; cwd?: string } ): ManagedPane | null { const existing = this.panes.get(paneId) if (!existing) { @@ -130,7 +130,14 @@ export class PaneManager { this.applyDividerStylesWrapped() newPane.terminal?.focus() updateMultiPaneState(this.getDragCallbacks()) - void this.options.onPaneCreated?.(toPublicPane(newPane)) + // Why: forward the caller's spawn hint so onPaneCreated → connectPanePty + // can boot the new PTY in the source pane's live cwd instead of the + // worktree root. The hint is synchronous-only: splitPane returns after + // onPaneCreated runs, so there is no reason for it to outlive this call. + void this.options.onPaneCreated?.( + toPublicPane(newPane), + opts?.cwd ? { cwd: opts.cwd } : undefined + ) this.options.onLayoutChanged?.() scheduleSplitScrollRestore(