Skip to content

Commit 360f127

Browse files
nedtwiggclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 9146b1d commit 360f127

5 files changed

Lines changed: 71 additions & 4 deletions

File tree

docs/specs/layout.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ All handled in a single capture-phase `keydown` listener on `window`. Every hand
179179
| `t` | Toggle TODO flag ||
180180
| `a` | Dismiss or toggle alert ||
181181

182+
### Split cwd inheritance
183+
184+
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`.
185+
182186
### Kill confirmation
183187

184188
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.

lib/src/components/Wall.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
toggleSessionTodo,
1919
setPendingShellOpts,
2020
getDefaultShellOpts,
21+
getTerminalPaneState,
2122
isUntouched,
2223
setTerminalUserTitle,
2324
UNNAMED_PANEL_TITLE,
@@ -610,8 +611,12 @@ export function Wall({
610611
const ref = id && api.getPanel(id) ? id : null;
611612
// Carry the currently-selected shell into the split, same as [+].
612613
const defaults = getDefaultShellOpts();
613-
if (defaults?.shell) {
614-
setPendingShellOpts(newId, { shell: defaults.shell, args: defaults.args });
614+
// Inherit the source pane's cwd when known and local (diffplug/mouseterm#4).
615+
// Remote cwds (e.g. from OSC 7 over ssh) aren't usable as a local spawn cwd.
616+
const sourceCwd = ref ? getTerminalPaneState(ref).cwd : null;
617+
const inheritedCwd = sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined;
618+
if (defaults?.shell || inheritedCwd) {
619+
setPendingShellOpts(newId, { shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd });
615620
}
616621
// Horizontal split places the new pane to the right → reveal from its left edge.
617622
// Vertical split places it below → reveal from its top edge.

lib/src/lib/terminal-lifecycle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}):
207207
return entry;
208208
}
209209

210-
export function setPendingShellOpts(id: string, opts: { shell?: string; args?: string[] }): void {
210+
export function setPendingShellOpts(id: string, opts: { shell?: string; args?: string[]; cwd?: string }): void {
211211
pendingShellOpts.set(id, opts);
212212
}
213213

lib/src/lib/terminal-registry.alert.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
markSessionTodo,
108108
resumeTerminal,
109109
restoreTerminal,
110+
setPendingShellOpts,
110111
swapTerminals,
111112
toggleSessionAlert,
112113
toggleSessionTodo,
@@ -962,3 +963,60 @@ describe('terminal-registry alert behavior', () => {
962963
});
963964
});
964965
});
966+
967+
describe('pending shell opts → spawnPty', () => {
968+
let spawnSpy: ReturnType<typeof vi.spyOn>;
969+
970+
beforeEach(() => {
971+
vi.useFakeTimers();
972+
fakePlatform.reset();
973+
initAlertStateReceiver();
974+
const documentElement = new MockElement();
975+
vi.stubGlobal('document', {
976+
createElement: () => new MockElement(),
977+
documentElement,
978+
});
979+
vi.stubGlobal('getComputedStyle', () => ({
980+
getPropertyValue: () => '#000000',
981+
}));
982+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
983+
callback(0);
984+
return 0;
985+
});
986+
vi.stubGlobal('MutationObserver', class { observe() {} disconnect() {} });
987+
vi.stubGlobal('window', {
988+
addEventListener: () => {},
989+
removeEventListener: () => {},
990+
});
991+
spawnSpy = vi.spyOn(fakePlatform, 'spawnPty');
992+
});
993+
994+
afterEach(() => {
995+
disposeAllSessions();
996+
fakePlatform.reset();
997+
spawnSpy.mockRestore();
998+
vi.unstubAllGlobals();
999+
vi.useRealTimers();
1000+
});
1001+
1002+
it('forwards a pending cwd to spawnPty (split inherits source pane cwd)', () => {
1003+
const id = 'split-with-cwd';
1004+
1005+
setPendingShellOpts(id, { shell: '/bin/zsh', args: ['-l'], cwd: '/home/user/project' });
1006+
getOrCreateTerminal(id);
1007+
1008+
expect(spawnSpy).toHaveBeenCalledWith(
1009+
id,
1010+
expect.objectContaining({ shell: '/bin/zsh', args: ['-l'], cwd: '/home/user/project' }),
1011+
);
1012+
});
1013+
1014+
it('omits cwd when no pending opts were set', () => {
1015+
const id = 'split-without-cwd';
1016+
1017+
getOrCreateTerminal(id);
1018+
1019+
const options = spawnSpy.mock.calls[0]?.[1] as { cwd?: string } | undefined;
1020+
expect(options?.cwd).toBeUndefined();
1021+
});
1022+
});

lib/src/lib/terminal-store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface TerminalOverlayDims {
3737
}
3838

3939
export const registry = new Map<string, TerminalEntry>();
40-
export const pendingShellOpts = new Map<string, { shell?: string; args?: string[] }>();
40+
export const pendingShellOpts = new Map<string, { shell?: string; args?: string[]; cwd?: string }>();
4141

4242
export function getEntryByPtyId(ptyId: string): TerminalEntry | null {
4343
for (const entry of registry.values()) {

0 commit comments

Comments
 (0)