Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
toggleSessionTodo,
setPendingShellOpts,
getDefaultShellOpts,
getTerminalPaneState,
isUntouched,
setTerminalUserTitle,
UNNAMED_PANEL_TITLE,
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { extractSelectionText } from './selection-text';
import {
pendingShellOpts,
registry,
type PendingShellOpts,
type TerminalEntry,
type TerminalOverlayDims,
} from './terminal-store';
Expand Down Expand Up @@ -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);
}

Expand Down
95 changes: 67 additions & 28 deletions lib/src/lib/terminal-registry.alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
markSessionTodo,
resumeTerminal,
restoreTerminal,
setPendingShellOpts,
swapTerminals,
toggleSessionAlert,
toggleSessionTodo,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -962,3 +966,38 @@ describe('terminal-registry alert behavior', () => {
});
});
});

describe('pending shell opts → spawnPty', () => {
let spawnSpy: ReturnType<typeof vi.spyOn>;

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();
});
});
8 changes: 7 additions & 1 deletion lib/src/lib/terminal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ export interface TerminalOverlayDims {
gridTop: number;
}

export interface PendingShellOpts {
shell?: string;
args?: string[];
cwd?: string;
}

export const registry = new Map<string, TerminalEntry>();
export const pendingShellOpts = new Map<string, { shell?: string; args?: string[] }>();
export const pendingShellOpts = new Map<string, PendingShellOpts>();

export function getEntryByPtyId(ptyId: string): TerminalEntry | null {
for (const entry of registry.values()) {
Expand Down
Loading