From 360f127c745531057b427b5b248a5e65d5bb2e01 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 14 May 2026 19:34:14 -0700 Subject: [PATCH 1/2] Inherit cwd from the source pane when splitting (#4) When a split is initiated from an existing pane, the new pane spawns with the source pane's last-known cwd. Remote cwds (e.g. OSC 7 over ssh) are skipped since they aren't usable as a local spawn directory. The inherited cwd rides through setPendingShellOpts alongside the inherited shell selection and is consumed by getOrCreateTerminal on the next platform.spawnPty. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/layout.md | 4 ++ lib/src/components/Wall.tsx | 9 +++- lib/src/lib/terminal-lifecycle.ts | 2 +- lib/src/lib/terminal-registry.alert.test.ts | 58 +++++++++++++++++++++ lib/src/lib/terminal-store.ts | 2 +- 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index e1ff4b7b..f05c1fec 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -179,6 +179,10 @@ All handled in a single capture-phase `keydown` listener on `window`. Every hand | `t` | Toggle TODO flag | — | | `a` | Dismiss or toggle alert | — | +### Split cwd inheritance + +When a split is initiated from an existing pane (via `"`/`%`, the header split buttons, or `Cmd/Ctrl+Click` on a split icon), the new pane spawns with its source pane's last-known cwd as the spawn directory. The source cwd is read from `getTerminalPaneState(sourceId).cwd`; remote cwds (`isRemote === true`, e.g. an OSC 7 path reported over ssh) are ignored because they aren't usable as a local spawn cwd. When no source cwd is known, when the split has no source pane (initial pane creation), or when the source is remote, the host's default cwd applies. The inherited cwd rides through `setPendingShellOpts` alongside the inherited shell selection and is consumed by `getOrCreateTerminal` on the next `platform.spawnPty`. + ### Kill confirmation Pressing `x` (or clicking the kill button) enters command mode and shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmCard`) with a random uppercase letter (A-Z, excluding X). Typing that letter confirms the kill (destroys session, removes pane). Cancel with Escape key, clicking the `[ESC] to cancel` button, or clicking another panel. Any other key triggers a shake animation (400ms `shake-x` keyframe) then auto-dismisses the confirmation. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index cf4141ed..14c73ed8 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -18,6 +18,7 @@ import { toggleSessionTodo, setPendingShellOpts, getDefaultShellOpts, + getTerminalPaneState, isUntouched, setTerminalUserTitle, UNNAMED_PANEL_TITLE, @@ -610,8 +611,12 @@ export function Wall({ const ref = id && api.getPanel(id) ? id : null; // Carry the currently-selected shell into the split, same as [+]. const defaults = getDefaultShellOpts(); - if (defaults?.shell) { - setPendingShellOpts(newId, { shell: defaults.shell, args: defaults.args }); + // Inherit the source pane's cwd when known and local (diffplug/mouseterm#4). + // Remote cwds (e.g. from OSC 7 over ssh) aren't usable as a local spawn cwd. + const sourceCwd = ref ? getTerminalPaneState(ref).cwd : null; + const inheritedCwd = sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined; + if (defaults?.shell || inheritedCwd) { + setPendingShellOpts(newId, { shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd }); } // Horizontal split places the new pane to the right → reveal from its left edge. // Vertical split places it below → reveal from its top edge. diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index c9ed5ff0..f215ce54 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -207,7 +207,7 @@ function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}): return entry; } -export function setPendingShellOpts(id: string, opts: { shell?: string; args?: string[] }): void { +export function setPendingShellOpts(id: string, opts: { shell?: string; args?: string[]; cwd?: string }): void { pendingShellOpts.set(id, opts); } diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 00f2d1f7..867e6de1 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -107,6 +107,7 @@ import { markSessionTodo, resumeTerminal, restoreTerminal, + setPendingShellOpts, swapTerminals, toggleSessionAlert, toggleSessionTodo, @@ -962,3 +963,60 @@ describe('terminal-registry alert behavior', () => { }); }); }); + +describe('pending shell opts → spawnPty', () => { + let spawnSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + fakePlatform.reset(); + initAlertStateReceiver(); + const documentElement = new MockElement(); + vi.stubGlobal('document', { + createElement: () => new MockElement(), + documentElement, + }); + vi.stubGlobal('getComputedStyle', () => ({ + getPropertyValue: () => '#000000', + })); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + vi.stubGlobal('MutationObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('window', { + addEventListener: () => {}, + removeEventListener: () => {}, + }); + spawnSpy = vi.spyOn(fakePlatform, 'spawnPty'); + }); + + afterEach(() => { + disposeAllSessions(); + fakePlatform.reset(); + spawnSpy.mockRestore(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('forwards a pending cwd to spawnPty (split inherits source pane cwd)', () => { + const id = 'split-with-cwd'; + + setPendingShellOpts(id, { shell: '/bin/zsh', args: ['-l'], cwd: '/home/user/project' }); + getOrCreateTerminal(id); + + expect(spawnSpy).toHaveBeenCalledWith( + id, + expect.objectContaining({ shell: '/bin/zsh', args: ['-l'], cwd: '/home/user/project' }), + ); + }); + + it('omits cwd when no pending opts were set', () => { + const id = 'split-without-cwd'; + + getOrCreateTerminal(id); + + const options = spawnSpy.mock.calls[0]?.[1] as { cwd?: string } | undefined; + expect(options?.cwd).toBeUndefined(); + }); +}); diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index ae5faba5..7ca5ee95 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -37,7 +37,7 @@ export interface TerminalOverlayDims { } export const registry = new Map(); -export const pendingShellOpts = new Map(); +export const pendingShellOpts = new Map(); export function getEntryByPtyId(ptyId: string): TerminalEntry | null { for (const entry of registry.values()) { From 996e0bc6e4280e48df74eff2a7519a690cc7d2c9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 15 May 2026 15:57:52 -0700 Subject: [PATCH 2/2] Tidy split cwd handling: name PendingShellOpts, share test setup - Extract `PendingShellOpts` interface in terminal-store so the `{ shell?; args?; cwd? }` shape stops being repeated across the store and the lifecycle module. - Drop a stale narrative comment in Wall.tsx; tighten the remaining one to explain why remote cwds are skipped. - Hoist the duplicated `vi.stubGlobal` setup in terminal-registry.alert.test.ts into shared install/uninstallRegistryTestGlobals helpers so the new pending-shell-opts describe block reuses the existing setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Wall.tsx | 3 +- lib/src/lib/terminal-lifecycle.ts | 3 +- lib/src/lib/terminal-registry.alert.test.ts | 87 ++++++++------------- lib/src/lib/terminal-store.ts | 8 +- 4 files changed, 44 insertions(+), 57 deletions(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 14c73ed8..584dc849 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -611,8 +611,7 @@ export function Wall({ const ref = id && api.getPanel(id) ? id : null; // Carry the currently-selected shell into the split, same as [+]. const defaults = getDefaultShellOpts(); - // Inherit the source pane's cwd when known and local (diffplug/mouseterm#4). - // Remote cwds (e.g. from OSC 7 over ssh) aren't usable as a local spawn cwd. + // Remote cwds (OSC 7 over ssh) name a path on the remote host, not one the local shell can chdir to. const sourceCwd = ref ? getTerminalPaneState(ref).cwd : null; const inheritedCwd = sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined; if (defaults?.shell || inheritedCwd) { diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index f215ce54..714d29e6 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -12,6 +12,7 @@ import { extractSelectionText } from './selection-text'; import { pendingShellOpts, registry, + type PendingShellOpts, type TerminalEntry, type TerminalOverlayDims, } from './terminal-store'; @@ -207,7 +208,7 @@ function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}): return entry; } -export function setPendingShellOpts(id: string, opts: { shell?: string; args?: string[]; cwd?: string }): void { +export function setPendingShellOpts(id: string, opts: PendingShellOpts): void { pendingShellOpts.set(id, opts); } diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 867e6de1..56e88ad3 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -221,37 +221,40 @@ function driveToRingingNeedsAttention(id: string): void { expect(getActivity(id).status).toBe('ALERT_RINGING'); } -describe('terminal-registry alert behavior', () => { - beforeEach(() => { - vi.useFakeTimers(); - fakePlatform.reset(); - initAlertStateReceiver(); - - const documentElement = new MockElement(); - vi.stubGlobal('document', { - createElement: () => new MockElement(), - documentElement, - }); - vi.stubGlobal('getComputedStyle', () => ({ - getPropertyValue: () => '#000000', - })); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0); - return 0; - }); - vi.stubGlobal('MutationObserver', class { observe() {} disconnect() {} }); - vi.stubGlobal('window', { - addEventListener: () => {}, - removeEventListener: () => {}, - }); +function installRegistryTestGlobals(): void { + vi.useFakeTimers(); + fakePlatform.reset(); + initAlertStateReceiver(); + + const documentElement = new MockElement(); + vi.stubGlobal('document', { + createElement: () => new MockElement(), + documentElement, + }); + vi.stubGlobal('getComputedStyle', () => ({ + getPropertyValue: () => '#000000', + })); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + vi.stubGlobal('MutationObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('window', { + addEventListener: () => {}, + removeEventListener: () => {}, }); +} - afterEach(() => { - disposeAllSessions(); - fakePlatform.reset(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); +function uninstallRegistryTestGlobals(): void { + disposeAllSessions(); + fakePlatform.reset(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +} + +describe('terminal-registry alert behavior', () => { + beforeEach(installRegistryTestGlobals); + afterEach(uninstallRegistryTestGlobals); it('starts brand-new sessions as untouched', () => { const id = 'new-untouched'; @@ -968,35 +971,13 @@ describe('pending shell opts → spawnPty', () => { let spawnSpy: ReturnType; beforeEach(() => { - vi.useFakeTimers(); - fakePlatform.reset(); - initAlertStateReceiver(); - const documentElement = new MockElement(); - vi.stubGlobal('document', { - createElement: () => new MockElement(), - documentElement, - }); - vi.stubGlobal('getComputedStyle', () => ({ - getPropertyValue: () => '#000000', - })); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0); - return 0; - }); - vi.stubGlobal('MutationObserver', class { observe() {} disconnect() {} }); - vi.stubGlobal('window', { - addEventListener: () => {}, - removeEventListener: () => {}, - }); + installRegistryTestGlobals(); spawnSpy = vi.spyOn(fakePlatform, 'spawnPty'); }); afterEach(() => { - disposeAllSessions(); - fakePlatform.reset(); spawnSpy.mockRestore(); - vi.unstubAllGlobals(); - vi.useRealTimers(); + uninstallRegistryTestGlobals(); }); it('forwards a pending cwd to spawnPty (split inherits source pane cwd)', () => { diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 7ca5ee95..ad7c82f1 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -36,8 +36,14 @@ export interface TerminalOverlayDims { gridTop: number; } +export interface PendingShellOpts { + shell?: string; + args?: string[]; + cwd?: string; +} + export const registry = new Map(); -export const pendingShellOpts = new Map(); +export const pendingShellOpts = new Map(); export function getEntryByPtyId(ptyId: string): TerminalEntry | null { for (const entry of registry.values()) {