16 KiB
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)
src/renderer/src/components/terminal-pane/keyboard-handlers.ts:283dispatches onaction.type === 'splitActivePane'; themanager.splitPane(pane.id, direction)call is at:296.src/renderer/src/lib/pane-manager/pane-manager.ts:93 splitPane()creates the new pane and firesonPaneCreated(newPane).src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts:349 onPaneCreated→connectPanePty(pane, manager, {...ptyDeps}).src/renderer/src/components/terminal-pane/pty-connection.ts:272builds the transport withcwd: deps.cwd.deps.cwdis set once per tab at mount from theTerminalPaneprop, alwaysworktree.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— regexESC]7;file://[^/]*(/...)BEL|ST, URI-decodes the path, returns the most recent match in a data chunk.useTerminalCwd.ts— per-pane hook: seeds frominitialCwd/workspaceCwd, callsparseCwdon 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):
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<Map<number, string>>(new Map()). - Disposable tracked in an
osc7DisposablesRefmap parallel toosc52DisposablesRef, torn down inonPaneClosed(seeuse-terminal-pane-lifecycle.ts:481-484for the OSC-52 pattern).onPaneClosedmust alsopaneCwdRef.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
onPaneCreatedbody (use-terminal-pane-lifecycle.ts:349-460) installs OSC parser handlers synchronously beforeconnectPanePty. 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
cdthat no longer reflects the live shell cwd.paneCwdRefdoes not distinguish replayed vs. live entries. Store{cwd, confirmed: boolean}and markconfirmed = falsewhenisPaneReplaying(...)is true at handler fire time,confirmed = trueotherwise.resolveSplitCwdprefers aconfirmedentry and otherwise falls through topty.getCwd, which reflects live shell state via/procorlsof. Why the flag is reliable during replay:replayIntoTerminal(replay-guard.ts:37-55) increments the counter beforepane.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 changereplay-guard.tsto a pre-writedecrement — 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<string>. 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.
- If
paneCwdRef.current.get(sourcePaneId)hasconfirmed === true, return itscwdsynchronously (no await). OSC-7 from a live shell is authoritative and instant. - Otherwise
await window.api.pty.getCwd(sourcePtyId)with a ~200ms soft timeout enforced renderer-side viaPromise.race— keeps the "fall through to fallback" semantics in one place rather than threading timeouts into the provider. Use the result if non-empty. - Otherwise, if
paneCwdRef.current.get(sourcePaneId)hasconfirmed === false(replayed OSC-7 only), return itscwdas a last-ditch guess before falling back. - 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
paneCwdRefsynchronously. If hit → callsplitPaneimmediately (noawait). - If miss → fire-and-forget an
asyncIIFE that awaitspty.getCwdthen callssplitPane. 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.onPaneCreatedtype declaration inpane-manager.ts.- The
createPaneinvocation atpane-manager.ts:89passesundefinedforspawnHints(new panes aren't splits). - The
splitPaneinvocation atpane-manager.ts:133forwardsopts?.cwdas{ cwd: opts.cwd }when set.
In use-terminal-pane-lifecycle.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<string> and replace manager.splitPane(pane.id, dir) with roughly:
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/<pid>/cwd or lsof. Changes needed:
LocalPtyProvider.getCwd(src/main/providers/local-pty-provider.ts:388) currentlythrows when the id is unknown and returns''otherwise. Change: return''on unknown id (do not throw), and on a known id callresolveProcessCwd(proc.pid, '')whereproc = 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 insrc/relay/pty-shell-utils.tsand is bundled into the relay binary. The Electron main process and the relay have separate build graphs; having main import fromsrc/relay/(or vice-versa) is not a pattern this repo uses. Rather than force asrc/shared/split (and touch the relay build), keep the relay's copy in place and add a tiny duplicate insrc/main/providers/process-cwd.tsforLocalPtyProvider. 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))insrc/main/ipc/pty.ts, and addipcMain.removeHandler('pty:getCwd')to the removeHandler block atpty.ts:325-331(next to the existingpty:hasChildProcesses/pty:getForegroundProcessentries). - Preload: add
pty.getCwd(id: string): Promise<string>insrc/preload/index.tsalongsidehasChildProcesses/getForegroundProcess, and mirror insrc/preload/api-types.ts. Returns''when unknown.
5. Edge cases
- Agent TUIs (Claude Code, Codex, cursor-agent). No OSC-7 because no shell prompt.
paneCwdRefstays empty;pty.getCwdvia/proc/<pid>/cwdstill resolves against the running agent process. - Just-split panes (no OSC-7 yet). A fresh pane before its first prompt has no entry.
pty.getCwdfallback 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 viaterminal.write(replay-guard.ts:47) and repopulates the map withconfirmed: false. The first live OSC-7 after the first prompt upgrades it toconfirmed: true. If the user hits Cmd+D before the first live prompt,resolveSplitCwdtakes thepty.getCwdpath (which queries/proc/<pid>/cwdon the live shell) and only falls back to the replayed entry if IPC yields nothing. - Non-emitting shells. Minimal
shwithout OSC-7 →pty.getCwdfallback carries it. Explicit reason to keep the two-layer strategy. - Windows.
/procis absent andlsofisn't native. On Windows,LocalPtyProvider.getCwdmust return''(fall through to worktree root) and must not throw — after the §4 change, the existingthrow-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:286exits 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,parseOsc7must return a string thatnode-ptyaccepts ascwdonspawn(backslash- or forward-slash-separated, with drive letter, no leading/). Normalize insideparseOsc7viapath.win32.normalizewhenprocess.platform === 'win32'and assert the test output equals whatspawn'scwdoption tolerates — don't leave the choice implicit. - Unit:
resolveSplitCwdpriority — OSC-7 cache hit skips IPC; cache miss queries IPC; empty IPC result falls through todeps.cwd; IPC timeout falls through todeps.cwd. - Unit:
LocalPtyProvider.getCwdreturns''(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.getCwdboth 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+resolveSplitCwdwhen the new-tab flow is revisited. - Using tracked CWD for status-bar display or any other non-split consumer. Separate features that can reuse
paneCwdReflater. - CWD for context-menu entries that open external tools (file manager, editor). Those already have their own resolution paths.