Skip to content
Open
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
2 changes: 1 addition & 1 deletion dor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ function validateEnsureDelimiter(args: string[]): ParseResult<void> {

for (let index = 0; index < delimiterIndex; index += 1) {
const arg = args[index];
if (arg === '--json' || arg === '--minimize') {
if (arg === '--json' || arg === '--minimize' || arg === '--restart') {
continue;
}
if (arg === '--cwd' || arg === '--surface') {
Expand Down
21 changes: 16 additions & 5 deletions dor/src/commands/ensure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,31 @@ import {
interface EnsureFlags {
readonly json?: boolean;
readonly minimize?: boolean;
readonly restart?: boolean;
readonly surface?: string;
readonly cwd?: string;
}

// `--restart` makes the host block until a server is interrupted and respawned,
// which can outlast the client's default 5s request timeout. Give that one
// command plenty of headroom; everything else keeps the snappy default.
const RESTART_TIMEOUT_MS = 60_000;

export const ensureCommand: Command = {
name: 'ensure',
helpPatches: [
{
scope: 'root',
findReplace: [
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...\n',
],
},
{
scope: 'command-usage',
findReplace: [
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...\n',
],
},
{
Expand All @@ -60,11 +66,14 @@ Two surfaces running the same command in different working directories are disti

--minimize applies only when creating a new surface; it does not minimize an existing match.

--restart applies only to an already-running match: it interrupts the live command (Ctrl+C), waits for the shell to return to its prompt, then re-runs the command in place and blocks until the command is live again. A restarted surface keeps its minimized/visible state. If no surface is running the command, --restart behaves like a plain ensure and creates one.

--surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split.

Text output:
created surface:3 "npm run dev"
existing surface:3 "npm run dev"
restarted surface:3 "npm run dev"

JSON output:
{
Expand All @@ -80,6 +89,7 @@ JSON output:
flags: {
json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false },
minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false },
restart: { kind: 'boolean', brief: 'Restart a matching surface in place.', optional: true, withNegated: false },
surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref|index' },
cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for matching and for the new command.', optional: true, placeholder: 'path' },
},
Expand All @@ -98,13 +108,14 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ...
return new Error('dor ensure requires a command after --');
}

const client = requireControlClient(this.options);
const client = requireControlClient(this.options, flags.restart === true ? RESTART_TIMEOUT_MS : undefined);
if (client instanceof Error) return client;

try {
const response = await client.ensureSurface({
command: commandArgs,
minimized: flags.minimize === true,
restart: flags.restart === true,
surface: flags.surface,
cwd: callerWorkingDirectory(flags.cwd, this.options.env),
});
Expand Down
10 changes: 7 additions & 3 deletions dor/src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function parseIdFormat(value: string): IdFormat {
throw new SyntaxError(`invalid --id-format '${value}'`);
}

function resolveControlClient(options: CliOptions): ParseResult<ControlClient> {
function resolveControlClient(options: CliOptions, timeoutMs?: number): ParseResult<ControlClient> {
if (options.client) return { ok: true, value: options.client };

const env = options.env ?? {};
Expand All @@ -47,12 +47,16 @@ function resolveControlClient(options: CliOptions): ParseResult<ControlClient> {
socketPath,
token,
surfaceId: env.DORMOUSE_SURFACE_ID,
...(timeoutMs === undefined ? {} : { timeoutMs }),
}),
};
}

export function requireControlClient(options: CliOptions): ControlClient | Error {
const result = resolveControlClient(options);
// `timeoutMs` overrides the client's default request timeout for commands that
// intentionally block the host (e.g. `dor ensure --restart` waits for a server
// to die and respawn). Ignored when a client is injected (tests).
export function requireControlClient(options: CliOptions, timeoutMs?: number): ControlClient | Error {
const result = resolveControlClient(options, timeoutMs);
return result.ok ? result.value : new Error(result.message);
}

Expand Down
4 changes: 3 additions & 1 deletion dor/src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ export interface EnsureSurfaceRequest {
/** Raw argv for the command; the host quotes it for the target shell. */
command: string[];
minimized: boolean;
/** Interrupt and re-run a matching surface in place instead of reusing it. */
restart: boolean;
surface?: string;
/** Working directory for matching and for the new command; part of the idempotency key. */
cwd: string;
}

export interface EnsureSurfaceResponse {
status: 'created' | 'existing';
status: 'created' | 'existing' | 'restarted';
surfaceId?: string;
surfaceRef: string;
command: string;
Expand Down
16 changes: 15 additions & 1 deletion dor/test/cli-output.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) {
// Mirror the host: quote the argv for the target shell, and key on the
// command so the fixture can exercise both the created and existing paths.
const command = buildShellCommandForKind('posix', request.command);
const isExisting = command === 'pnpm dev:workspace';
return {
status: command === 'pnpm dev:workspace' ? 'existing' : 'created',
status: isExisting ? (request.restart ? 'restarted' : 'existing') : 'created',
surfaceId: '33333333-3333-4333-8333-333333333333',
surfaceRef: 'surface:3',
command,
Expand Down Expand Up @@ -239,12 +240,25 @@ test('ensure sends command argv and caller cwd to the host', async () => {
request: {
command: ['pnpm', 'dev'],
minimized: false,
restart: false,
surface: undefined,
cwd: '/work/site',
},
}]);
});

test('ensure --restart restarts a matching surface in place', async () => {
const client = fixtureClient();
await snapshot(
'ensure-restart',
await runCli(['ensure', '--restart', '--', 'pnpm', 'dev:workspace'], {
client,
env: { PWD: '/work/site' },
}),
);
assert.equal(client.requests[0].request.restart, true);
});

test('ensure json output', async () => {
await snapshot(
'ensure-json',
Expand Down
5 changes: 5 additions & 0 deletions dor/test/snapshots/ensure-restart.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
exitCode: 0
stdout:
restarted surface:3 "pnpm dev:workspace"

stderr:
2 changes: 1 addition & 1 deletion dor/test/snapshots/help/dor.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Invocation: `dor --help`
```text
USAGE
dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref|index] [-- <command>...]
dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...
dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...
dor version
dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [<text>]
dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index]
Expand Down
6 changes: 5 additions & 1 deletion dor/test/snapshots/help/ensure.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Invocation: `dor ensure --help`

```text
USAGE
dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...
dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...
dor ensure --help

Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command.
Expand All @@ -19,11 +19,14 @@ Two surfaces running the same command in different working directories are disti

--minimize applies only when creating a new surface; it does not minimize an existing match.

--restart applies only to an already-running match: it interrupts the live command (Ctrl+C), waits for the shell to return to its prompt, then re-runs the command in place and blocks until the command is live again. A restarted surface keeps its minimized/visible state. If no surface is running the command, --restart behaves like a plain ensure and creates one.

--surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split.

Text output:
created surface:3 "npm run dev"
existing surface:3 "npm run dev"
restarted surface:3 "npm run dev"

JSON output:
{
Expand All @@ -38,6 +41,7 @@ JSON output:
FLAGS
[--json] Print JSON output.
[--minimize] Create the surface minimized.
[--restart] Restart a matching surface in place.
[--surface] Surface to split when creating.
[--cwd] Working directory for matching and for the new command.
-h --help Print help information and exit
Expand Down
78 changes: 77 additions & 1 deletion lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
createTerminalPaneState,
deriveSurfaceLabel,
surfaceRunsCommand,
type TerminalPaneState,
} from '../lib/terminal-state';
import { orchestrateKill } from '../lib/kill-animation';
import { getPlatform, PLATFORM_STRING } from '../lib/platform';
Expand Down Expand Up @@ -103,6 +104,7 @@ type DorControlParams = {
key?: unknown;
lines?: unknown;
minimized?: unknown;
restart?: unknown;
binaryPath?: unknown;
pane?: string;
session?: unknown;
Expand Down Expand Up @@ -247,6 +249,60 @@ function readSurfaceText(surfaceId: string, lines: number | undefined, scrollbac
return limitLines(collected.join('\n').replace(/\n+$/, ''), lines);
}

// `dor ensure --restart` blocks the CLI while we interrupt a live command and
// re-run it. Rather than guess at timings, poll the integration-derived
// terminal state: a command is gone once `currentCommand` clears (commandFinish
// → prompt) and back once the surface reports the same command live again.
const RESTART_POLL_INTERVAL_MS = 100;
const RESTART_INTERRUPT_TIMEOUT_MS = 15_000;
const RESTART_START_TIMEOUT_MS = 15_000;

/** Resolve true once `predicate` holds for the surface's live state, false on timeout. */
function waitForTerminalState(
id: string,
predicate: (state: TerminalPaneState) => boolean,
timeoutMs: number,
): Promise<boolean> {
if (predicate(getTerminalPaneState(id))) return Promise.resolve(true);
return new Promise((resolve) => {
let elapsed = 0;
const timer = setInterval(() => {
if (predicate(getTerminalPaneState(id))) {
clearInterval(timer);
resolve(true);
} else if ((elapsed += RESTART_POLL_INTERVAL_MS) >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, RESTART_POLL_INTERVAL_MS);
});
}

/**
* Restart a surface already running `command` in `cwd`: interrupt it (Ctrl+C),
* wait for the shell to return to its prompt, type the command again, and wait
* for it to go live. Drives the live PTY directly, so it works for minimized
* doors too (their PTY keeps running). Returns a message on failure.
*/
async function restartSurfaceInPlace(id: string, command: string, cwd: string): Promise<ParseResult<undefined>> {
const platform = getPlatform();
platform.writePty(id, '\x03');
const interrupted = await waitForTerminalState(
id,
(state) => state.currentCommand === null,
RESTART_INTERRUPT_TIMEOUT_MS,
);
if (!interrupted) return { ok: false, message: 'did not return to a prompt after interrupt' };
platform.writePty(id, `${command}\r`);
const restarted = await waitForTerminalState(
id,
(state) => surfaceRunsCommand(state, command, cwd),
RESTART_START_TIMEOUT_MS,
);
if (!restarted) return { ok: false, message: 'command did not restart' };
return { ok: true, value: undefined };
}

function killConfirmationParam(value: unknown): { mode: 'if-read'; text: string } | { mode: 'dangerously' } | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const confirmation = value as { mode?: unknown; text?: unknown };
Expand Down Expand Up @@ -1155,6 +1211,26 @@ export function Wall({
}
const existingId = findSurfaceIdRunningCommand(command, cwd);
if (existingId) {
const minimized = doorsRef.current.some((door) => door.id === existingId);
if (booleanParam(params.restart)) {
const restarted = await restartSurfaceInPlace(existingId, command, cwd);
if (!restarted.ok) {
detail.respond({ ok: false, error: `surface '${surfaceRefForId(existingId)}' ${restarted.message}` });
return;
}
detail.respond({
ok: true,
result: {
status: 'restarted',
surfaceId: existingId,
surfaceRef: surfaceRefForId(existingId),
command,
cwd,
minimized,
},
});
return;
}
detail.respond({
ok: true,
result: {
Expand All @@ -1163,7 +1239,7 @@ export function Wall({
surfaceRef: surfaceRefForId(existingId),
command,
cwd,
minimized: doorsRef.current.some((door) => door.id === existingId),
minimized,
},
});
return;
Expand Down
Loading