Skip to content
Draft
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: 2 additions & 2 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ That order is load-bearing twice: a rename input suppresses the pane shortcuts b

A split from an existing pane (`|`/`%`/`-`/`"` or the header split buttons) spawns the new pane with its source pane's last-known cwd, then selects it and enters passthrough; host New Terminal actions share that focus tail (rationale). Focus-neutral control-plane creation (`dor split -- …`, `dor ensure`, `dor iframe`, `dor ab`) keeps its documented background behavior.

The source cwd is read from `getTerminalPaneState(sourceId).cwd`. **Never inherit a remote cwd** (`isRemote === true`, e.g. an OSC 7 path reported over ssh) — it is not a usable local spawn cwd. The host default applies when the source cwd is unknown, remote, or absent (initial pane creation). The inherited cwd rides `setPendingShellOpts` alongside the inherited shell selection, consumed by `getOrCreateTerminal` on the next `platform.spawnPty`.
The source cwd is read from `getInheritableCwd(sourceId)`. **Never inherit a remote cwd** (`isRemote === true`, e.g. an OSC 7 path reported over ssh) — it is not a usable local spawn cwd. The host default applies when the source cwd is unknown, remote, or absent (initial pane creation). The inherited cwd rides `setPendingShellOpts` alongside the inherited shell selection, consumed by `getOrCreateTerminal` on the next `platform.spawnPty`.

### Kill confirmation

Expand Down Expand Up @@ -497,7 +497,7 @@ Shell-selection replacement shows a short fixed-position notice over the resulti

### Auto-spawn refill

A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits.
A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). Like a split, it takes the default shell selection and **the departing pane's local cwd**.

**The refill adopts the replacement (`selectPane`) only when the current selection points at nothing real** — null (the kill tail cleared it after a selected last-pane kill) or dangling (still naming the just-removed pane). **A valid selection is left alone** — the just-created door on the minimize path, or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection.

Expand Down
45 changes: 34 additions & 11 deletions lib/src/components/Wall.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,29 @@ describe('Wall on the Lath engine', () => {
expect(leafCount()).toBe(1);
});

it.each(['Minimize', 'Kill'] as const)('starts the refill after %s of the last pane in that pane\'s cwd', async (control) => {
// The stubbed pane has no registry entry to tear down, so disposal is made to
// drop the pane state as the real teardown does.
vi.spyOn(terminalRegistry, 'disposeSession').mockImplementation((id) => terminalRegistry.removeTerminalPaneState(id));
terminalRegistry.seedTerminalManualCwd('pane-a', '/repo');
await act(async () => {
root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" showBaseboard />);
});
await flush();

await clickHeaderControl('pane-a', control);

const refillId = container.querySelector('[data-lath-leaf]')?.getAttribute('data-lath-leaf');
try {
expect(refillId).toBeTruthy();
expect(refillId).not.toBe('pane-a');
expect(pendingShellOpts.get(refillId!)?.cwd).toBe('/repo');
} finally {
if (refillId) pendingShellOpts.delete(refillId);
act(() => terminalRegistry.removeTerminalPaneState('pane-a'));
}
});

it('retires the old ref when shell selection replaces an untouched pane', async () => {
await act(async () => {
root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" showBaseboard />);
Expand Down Expand Up @@ -3141,15 +3164,15 @@ describe('Wall on the Lath engine', () => {
await flush();
}

/** The pane header's Kill button — a user-visible closure, which does prompt.
* `isUntouched` short-circuits the kill confirmation so this is one click. */
async function clickHeaderKill(paneId: string): Promise<void> {
/** A pane header control; Kill is a user-visible closure, which does prompt.
* `isUntouched` short-circuits the kill confirmation so a kill is one click. */
async function clickHeaderControl(paneId: string, label: 'Kill' | 'Minimize'): Promise<void> {
const untouched = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true);
try {
const button = container.querySelector<HTMLButtonElement>(
`[data-lath-leaf="${paneId}"] button[aria-label="Kill"]`,
`[data-lath-leaf="${paneId}"] button[aria-label="${label}"]`,
);
expect(button, `no Kill button on ${paneId}`).not.toBeNull();
expect(button, `no ${label} button on ${paneId}`).not.toBeNull();
await act(async () => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
Expand Down Expand Up @@ -3337,7 +3360,7 @@ describe('Wall on the Lath engine', () => {
const { dispose, setBusy } = spyOnHelper();
vi.spyOn(fake.notepadArchive, 'save').mockRejectedValue(new Error('disk full'));
await renderNotedPane();
await clickHeaderKill('pane-a');
await clickHeaderControl('pane-a', 'Kill');
expect(archiveFailureModal()).not.toBeNull();
setBusy(true);
await clickButton('Close anyway');
Expand Down Expand Up @@ -3373,7 +3396,7 @@ describe('Wall on the Lath engine', () => {
await flush();
act(() => { addPlainNote('pane-a', 'keep me'); });

await clickHeaderKill('pane-a');
await clickHeaderControl('pane-a', 'Kill');

expect(container.querySelector('[data-lath-leaf="pane-a"]')).not.toBeNull();
expect(getNotes('pane-a')).toHaveLength(1);
Expand Down Expand Up @@ -3408,7 +3431,7 @@ describe('Wall on the Lath engine', () => {
});
await flush();
act(() => { addPlainNote('pane-a', 'keep me'); });
await clickHeaderKill('pane-a');
await clickHeaderControl('pane-a', 'Kill');

await clickButton('Keep open');

Expand All @@ -3424,7 +3447,7 @@ describe('Wall on the Lath engine', () => {
});
await flush();
act(() => { addPlainNote('pane-a', 'expendable'); });
await clickHeaderKill('pane-a');
await clickHeaderControl('pane-a', 'Kill');

await clickButton('Close anyway');

Expand Down Expand Up @@ -3453,8 +3476,8 @@ describe('Wall on the Lath engine', () => {
addPlainNote('pane-b', 'from b');
});

await clickHeaderKill('pane-a');
await clickHeaderKill('pane-b');
await clickHeaderControl('pane-a', 'Kill');
await clickHeaderControl('pane-b', 'Kill');

// A's prompt is the one on screen; B's is behind it.
expect(archiveFailureModal()?.textContent).toContain('a could not be written');
Expand Down
44 changes: 22 additions & 22 deletions lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
toggleSessionTodo,
setPendingShellOpts,
getDefaultShellOpts,
getInheritableCwd,
getTerminalPaneState,
getTerminalPaneStateSnapshot,
getActivitySnapshot,
Expand Down Expand Up @@ -203,6 +204,13 @@ function createSurfaceRefRegistry(
return { refs, nextIndex: Math.max(persistedNext, max + 1) };
}

/** Stage `id`'s shell as the current default selection, started in `cwd`. Stages
* nothing when neither is set, leaving the host's own default shell and cwd. */
function stageDefaultShell(id: string, cwd: string | undefined): void {
const defaults = getDefaultShellOpts();
if (defaults?.shell || cwd) setPendingShellOpts(id, { shell: defaults?.shell, args: defaults?.args, cwd });
}

function compareBySurfaceRef(a: DorSurface, b: DorSurface): number {
return (surfaceRefNumber(a.ref) ?? Number.MAX_SAFE_INTEGER)
- (surfaceRefNumber(b.ref) ?? Number.MAX_SAFE_INTEGER);
Expand Down Expand Up @@ -724,12 +732,13 @@ export function Wall({
const exitMs = closingWorkspaceRef.current && workspaceIsCollapsed(effectiveWorkspaceId) ? 0 : lath.exitMs;
setTimeout(() => {
if (!lath.store.has(id)) return; // superseded meanwhile (e.g. replaced)
disposeSession(id);
// Live re-read at removal time: only a kill of the still-selected pane moves
// selection; navigating away mid-fade is honored. Removing the last leaf
// empties the tree and the auto-spawn effect fills it.
const wasSelectedPane = selectedTypeRef.current === 'pane' && selectedIdRef.current === id;
lath.store.removeLeaf(id);
// Dispose only after the removal: a refill it triggers reads this pane's cwd.
disposeSession(id);
// Forget the ref only now — while the pane is fading it is still in
// `listPanes()`, so an earlier delete would let a `dor` projection re-mint a
// fresh ref for the dying pane.
Expand Down Expand Up @@ -1044,13 +1053,13 @@ export function Wall({
);

/** Restore the Wall's "always one pane" rule after a commit empties the tree
* (last pane killed or minimized). A no-op while the tree is non-empty. */
const refillEmptyTree = useCallback(() => {
* (last pane killed or minimized), starting in `departedId`'s cwd. A no-op
* while the tree is non-empty. */
const refillEmptyTree = useCallback((departedId?: string) => {
if (lath.store.getSnapshot().tree.root !== null) return;
const id = generatePaneId();
surfaceRefForId(id);
const defaults = getDefaultShellOpts();
if (defaults?.shell) setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args });
stageDefaultShell(id, departedId ? getInheritableCwd(departedId) : undefined);
lath.store.setEnterHint(id, 'top-left'); // grows from the top-left as the killed pane shrank to the bottom-right
lath.store.addLeaf(id, terminalLeafMeta(), null); // becomes the root
// Adopt selection only when it points at nothing real: null, or dangling (a
Expand Down Expand Up @@ -1086,7 +1095,9 @@ export function Wall({
publishMembership();
}
if (closingWorkspaceRef.current) return;
refillEmptyTree();
// A commit that empties the tree took every pre-commit leaf with it.
const [departedId] = prevIds;
refillEmptyTree(departedId);
});
}, [lath, fireEvent, refillEmptyTree, publishMembership]);

Expand Down Expand Up @@ -1366,16 +1377,15 @@ export function Wall({
}

const newId = generatePaneId();
const defaults = getDefaultShellOpts();
// An explicit cwd (dor ensure --cwd, defaulting to the caller's directory)
// wins; otherwise inherit the reference pane's local cwd as dor split does.
const sourceCwd = getTerminalPaneState(referenceId).cwd;
const inheritedCwd = cwd ?? (sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined);
const inheritedCwd = cwd ?? getInheritableCwd(referenceId);

if (deferTerminal) {
// No pending shell opts at all: the terminal must not spawn when the leaf
// mounts, and must not inherit a cwd it will never use.
} else if (command) {
const defaults = getDefaultShellOpts();
// Spawn a real interactive shell and type the command into it once it
// reaches a prompt (see typeCommandWhenPromptReady in the lifecycle), rather
// than launching `shell -c command`. A `-c` invocation has no prompt behind
Expand All @@ -1394,12 +1404,8 @@ export function Wall({
command,
...(requireIntegration ? { requireIntegration: true } : {}),
});
} else if (defaults?.shell || inheritedCwd) {
setPendingShellOpts(newId, {
shell: defaults?.shell,
args: defaults?.args,
cwd: inheritedCwd,
});
} else {
stageDefaultShell(newId, inheritedCwd);
}

if (referenceDoor) {
Expand Down Expand Up @@ -1855,13 +1861,7 @@ export function Wall({
surfaceRefForId(newId);
const ref = id && nav.hasPane(id) ? id : null;
// Carry the currently selected shell into every manual split.
const defaults = getDefaultShellOpts();
// 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 });
}
stageDefaultShell(newId, ref ? getInheritableCwd(ref) : undefined);
const panes = lath.listPanes();
const refId = ref ?? (panes.length > 0 ? panes[panes.length - 1].id : null);
const edge: Edge = direction === 'right' ? 'right' : 'bottom';
Expand Down
12 changes: 11 additions & 1 deletion lib/src/lib/helper-terminal.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registry, pendingShellOpts, type TerminalEntry } from './terminal-store';
import { applyTerminalSemanticEvents, resetTerminalPaneState, removeTerminalPaneState } from './terminal-state-store';
import { applyTerminalSemanticEvents, getTerminalPaneState, resetTerminalPaneState, removeTerminalPaneState } from './terminal-state-store';
import { beginPromotion, cancelPromotion, closeHelperParent, disposeHelper, finishPromotion, getHelper, helperHasWork, openHelper, restoreHelper, setHelperVisible, subscribeHelpers } from './helper-terminal';

const host = vi.hoisted(() => ({ writePty: vi.fn(), terminalContext: vi.fn() }));
Expand Down Expand Up @@ -137,6 +137,16 @@ describe('helper lifecycle', () => {
prompt(first.id); await vi.advanceTimersByTimeAsync(500);
expect(first.status).toBe('completed'); expect(host.writePty).toHaveBeenCalledTimes(1);
});
it.each([
['a local parent cwd', false, '/work'],
['no cwd from a remote parent', true, undefined],
])('spawns and records %s', async (_label, isRemote, expected) => {
resetTerminalPaneState('parent', { cwd: { path: '/work', pathKind: 'posix', isRemote, source: 'osc7', updatedAt: 0 } });
const helper = await openHelper('parent');
expect(pendingShellOpts.get(helper.id)?.cwd).toBe(expected);
prompt(helper.id); await vi.advanceTimersByTimeAsync(100);
expect(getTerminalPaneState(helper.id).cwd?.path).toBe(expected);
});
it('user input during startup cancels autorun and remains preserved at idle', async () => {
const helper = await openHelper('parent'); registry.get(helper.id)!.untouched = false;
prompt(helper.id); await vi.advanceTimersByTimeAsync(100);
Expand Down
10 changes: 6 additions & 4 deletions lib/src/lib/helper-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getPlatform } from './platform';
import { registry } from './terminal-store';
import { disposeSession, getOrCreateTerminal, parkElement, setPendingShellOpts } from './terminal-lifecycle';
import { getDefaultShellOpts } from './shell-defaults';
import { getTerminalPaneState, isPaneOscDriven, seedLaunchedCommand } from './terminal-state-store';
import { getInheritableCwd, getTerminalPaneState, isPaneOscDriven, seedLaunchedCommand } from './terminal-state-store';
import { DEFAULT_HELPER_COMMAND, type HelperIdentity } from './terminal-context-types';

export type HelperStatus = 'waiting' | 'running' | 'completed' | 'preserved' | 'off' | 'unsupported' | 'exited';
Expand Down Expand Up @@ -156,15 +156,17 @@ export async function openHelper(parentId: string): Promise<HelperTerminal> {
const settings = await platform.terminalContext({ op: 'settings' });
if (!parentIsOpen(parentId)) throw new Error('The parent terminal has closed');
const id = `helper-${crypto.randomUUID()}`;
const cwd = getTerminalPaneState(parentId).cwd;
// One cwd for the spawn and the launched command's record, so a remote parent
// leaves the helper in the host default rather than claiming the ssh path.
const cwd = getInheritableCwd(parentId);
const command = settings.command ?? DEFAULT_HELPER_COMMAND;
const helper: HelperTerminal = { id, parentId, command, status: command ? 'waiting' : 'off' };
helpers.set(parentId, helper);
setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd: cwd && !cwd.isRemote ? cwd.path : undefined, helper: { parentId, command } });
setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd, helper: { parentId, command } });
getOrCreateTerminal(id);
parkElement(id);
notifyHelpers();
watchHelper(helper, cwd?.path);
watchHelper(helper, cwd);
return helper;
})();
pending.set(parentId, operation);
Expand Down
1 change: 1 addition & 0 deletions lib/src/lib/terminal-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export {
ensureTerminalPaneState,
fillTerminalProcessCwd,
getRunningCommandArgv0,
getInheritableCwd,
getTerminalPaneState,
getTerminalPaneStateSnapshot,
isPaneOscDriven,
Expand Down
7 changes: 7 additions & 0 deletions lib/src/lib/terminal-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export function getTerminalPaneState(id: string): TerminalPaneState {
return paneStates.get(id) ?? createTerminalPaneState();
}

/** The cwd a new local shell may inherit from `id`. A remote cwd (OSC 7 over ssh)
* names a path on the remote host, not one the local shell can chdir to. */
export function getInheritableCwd(id: string): string | undefined {
const cwd = paneStates.get(id)?.cwd;
return cwd && !cwd.isRemote ? cwd.path : undefined;
}

/**
* The bare program name of the pane's foreground command, or null when the pane
* is at a prompt (or its shell reported no command line). This is the key the
Expand Down
Loading