diff --git a/docs/specs/layout.md b/docs/specs/layout.md
index a82c3e2c1..7ed4f0ff7 100644
--- a/docs/specs/layout.md
+++ b/docs/specs/layout.md
@@ -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
@@ -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.
diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx
index e244fdd5c..4f89ae3c4 100644
--- a/lib/src/components/Wall.test.tsx
+++ b/lib/src/components/Wall.test.tsx
@@ -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();
+ });
+ 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();
@@ -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 {
+ /** 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 {
const untouched = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true);
try {
const button = container.querySelector(
- `[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 }));
});
@@ -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');
@@ -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);
@@ -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');
@@ -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');
@@ -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');
diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx
index 1aebb8424..123da0df9 100644
--- a/lib/src/components/Wall.tsx
+++ b/lib/src/components/Wall.tsx
@@ -45,6 +45,7 @@ import {
toggleSessionTodo,
setPendingShellOpts,
getDefaultShellOpts,
+ getInheritableCwd,
getTerminalPaneState,
getTerminalPaneStateSnapshot,
getActivitySnapshot,
@@ -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);
@@ -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.
@@ -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
@@ -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]);
@@ -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
@@ -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) {
@@ -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';
diff --git a/lib/src/lib/helper-terminal.test.ts b/lib/src/lib/helper-terminal.test.ts
index bf49f2ff8..80f9eee4d 100644
--- a/lib/src/lib/helper-terminal.test.ts
+++ b/lib/src/lib/helper-terminal.test.ts
@@ -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() }));
@@ -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);
diff --git a/lib/src/lib/helper-terminal.ts b/lib/src/lib/helper-terminal.ts
index 0557d4ed0..a7b411d2d 100644
--- a/lib/src/lib/helper-terminal.ts
+++ b/lib/src/lib/helper-terminal.ts
@@ -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';
@@ -156,15 +156,17 @@ export async function openHelper(parentId: string): Promise {
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);
diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts
index bb1a5c908..d69cd127a 100644
--- a/lib/src/lib/terminal-registry.ts
+++ b/lib/src/lib/terminal-registry.ts
@@ -103,6 +103,7 @@ export {
ensureTerminalPaneState,
fillTerminalProcessCwd,
getRunningCommandArgv0,
+ getInheritableCwd,
getTerminalPaneState,
getTerminalPaneStateSnapshot,
isPaneOscDriven,
diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts
index 1ee1be979..a569bd330 100644
--- a/lib/src/lib/terminal-state-store.ts
+++ b/lib/src/lib/terminal-state-store.ts
@@ -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