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..584dc849 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,11 @@ 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 }); + // 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) { + 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..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[] }): 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 00f2d1f7..56e88ad3 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, @@ -220,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(); +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: () => {}, - }); + const documentElement = new MockElement(); + vi.stubGlobal('document', { + createElement: () => new MockElement(), + documentElement, }); - - afterEach(() => { - disposeAllSessions(); - fakePlatform.reset(); - vi.unstubAllGlobals(); - vi.useRealTimers(); + 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 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'; @@ -962,3 +966,38 @@ describe('terminal-registry alert behavior', () => { }); }); }); + +describe('pending shell opts → spawnPty', () => { + let spawnSpy: ReturnType; + + beforeEach(() => { + installRegistryTestGlobals(); + spawnSpy = vi.spyOn(fakePlatform, 'spawnPty'); + }); + + afterEach(() => { + spawnSpy.mockRestore(); + uninstallRegistryTestGlobals(); + }); + + 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..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()) {