diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 76c5d3b6..a00b5d6e 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -253,7 +253,12 @@ whitespace characters), and `` (rest of line including the newline or EOF). Command help patches can target the `command-usage` section separately from `command-detail`. -- `dor split` [impl](../../dor/src/commands/split.ts) [docs](../../dor/test/snapshots/help/split.md) +- `dor split` [impl](../../dor/src/commands/split.ts) [docs](../../dor/test/snapshots/help/split.md). + Bare `dor split` focuses the new surface (in passthrough mode focus follows the + selection, so the user types straight into it); `dor split -- ` runs + the command in the background and leaves focus on the caller, like `dor ensure`. + Both are wired through `createSplitSurface`'s `focusNeutral` flag + (`lib/src/components/wall/use-dor-control.ts`). - `dor ensure` [impl](../../dor/src/commands/ensure.ts) [docs](../../dor/test/snapshots/help/ensure.md) - `dor version` [impl](../../dor/src/commands/version.ts) [docs](../../dor/test/snapshots/help/version.md) - `dor send` [impl](../../dor/src/commands/send.ts) [docs](../../dor/test/snapshots/help/send.md) diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index d5798862..6263f377 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -77,7 +77,7 @@ export const splitCommand: Command = { brief: 'Create a new terminal surface by splitting an existing surface.', fullDescription: `If no direction is provided, --auto is used. --auto chooses right when the target surface is wide and down when it is narrow. -Use -- followed by a command to run an initial command in the new terminal surface. +Use -- followed by a command to run an initial command in the new terminal surface. Bare split focuses the new surface so you can start typing in it; split with an initial command runs it in the background and leaves focus on the calling surface. --minimize creates the surface and immediately sends it to the minimized area. diff --git a/dor/test/snapshots/help/split.md b/dor/test/snapshots/help/split.md index 41cd7b93..031ca59a 100644 --- a/dor/test/snapshots/help/split.md +++ b/dor/test/snapshots/help/split.md @@ -9,7 +9,7 @@ USAGE If no direction is provided, --auto is used. --auto chooses right when the target surface is wide and down when it is narrow. -Use -- followed by a command to run an initial command in the new terminal surface. +Use -- followed by a command to run an initial command in the new terminal surface. Bare split focuses the new surface so you can start typing in it; split with an initial command runs it in the background and leaves focus on the calling surface. --minimize creates the surface and immediately sends it to the minimized area. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 307390cd..54a8554d 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -21,7 +21,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; // stubbed so panes mount cheaply. TerminalPanel still runs usePaneChrome (registering // the leaf element) and renders this stub inside its animation div. vi.mock('./TerminalPane', () => ({ - TerminalPane: ({ id }: { id: string }) =>
, + // `isFocused` is the Wall's focus decision for a pane (mode === 'passthrough' && + // selected) — the real component turns it into an xterm `.focus()`. Reflect it as + // a data attribute so focus-transfer tests can assert on it without a live xterm. + TerminalPane: ({ id, isFocused }: { id: string; isFocused?: boolean }) => ( +
+ ), })); let container: HTMLDivElement; @@ -187,6 +192,57 @@ describe('Wall on the Lath engine', () => { } }); + // The focus decision the Wall makes for a pane: `data-focused` on the mocked + // TerminalPane mirrors `mode === 'passthrough' && selected`. + const focusOf = (id: string): string | null => + container.querySelector(`[data-session-id="${id}"]`)?.getAttribute('data-focused') ?? null; + + async function dispatchSplit(params: Record): Promise { + let response: { ok: boolean; result?: { surfaceId?: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.split, + params, + respond: (r: typeof response) => { response = r; }, + }, + })); + }); + await flush(); + expect(response?.ok).toBe(true); + return response!.result!.surfaceId!; + } + + it('dor split transfers focus to the new surface (passthrough)', async () => { + await act(async () => { + root.render(); + }); + await flush(); + // The seeded pane starts focused (passthrough + selected). + expect(focusOf('pane-a')).toBe('true'); + + const newId = await dispatchSplit({ direction: 'right' }); + + // Focus moves to the freshly split surface; the caller is no longer focused. + expect(focusOf(newId)).toBe('true'); + expect(focusOf('pane-a')).toBe('false'); + }); + + it('dor split -- keeps focus on the calling surface (passthrough)', async () => { + await act(async () => { + root.render(); + }); + await flush(); + expect(focusOf('pane-a')).toBe('true'); + + const newId = await dispatchSplit({ direction: 'right', command: ['echo', 'hi'] }); + + // The initial command runs in the background: the caller keeps focus and the + // new surface is not focused. + expect(focusOf('pane-a')).toBe('true'); + expect(focusOf(newId)).toBe('false'); + }); + it('seeds multiple initial panes with the aspect-aware layout (geometry is measured before the seed)', async () => { // jsdom has no layout, so stub the container measurement wide. The seed reads the // store's geometry via `autoEdge`; if that geometry lags behind the measurement diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index f291cc47..3d6a7f97 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -725,9 +725,10 @@ export function Wall({ referenceId: string; cwd?: string; requireIntegration?: boolean; - // `dor ensure` must never move focus: the split is created in the background, - // leaving the caller's selection, mode, and DOM focus intact. Under Lath every - // add is inherently background (nothing re-parents or activates). + // `dor ensure` and `dor split -- ` must never move focus: the split + // is created in the background, leaving the caller's selection, mode, and DOM + // focus intact. Under Lath every add is inherently background (nothing + // re-parents or activates). focusNeutral?: boolean; }): ParseResult<{ id: string; @@ -767,7 +768,9 @@ export function Wall({ } // The split is inherently background: `dor split` (not focus-neutral) selects - // the new pane; `dor ensure` (focus-neutral) leaves selection put. + // the new pane — in passthrough mode selection carries DOM focus, so the user + // types straight into it; `dor split -- ` and `dor ensure` + // (focus-neutral) leave selection put. const edge = edgeForDorDirection(direction); lath.store.addLeaf(newId, terminalLeafMeta(), { refId: referenceId, edge }); const selectedNew = settleAddSelection(!!focusNeutral, false, newId); diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index d0090959..3be19059 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -390,6 +390,10 @@ export function useDorControl({ direction, minimized: booleanParam(params.minimized), referenceId: resolved.target.id, + // Bare `dor split` hands the user a terminal to work in, so focus moves + // to the new surface. `dor split -- ` launches the command in + // the background and leaves focus on the caller (like `dor ensure`). + focusNeutral: command !== undefined, }); if (!result.ok) { detail.respond({ ok: false, error: result.message });