From 1cd6c3b635a78bf55dfe2a648ee5122ba6b8f185 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 29 May 2026 14:54:08 -0700 Subject: [PATCH 1/7] Support dor title surface targets --- docs/specs/dor-cli.md | 3 +++ lib/src/components/Wall.tsx | 43 ++++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 89e1da7..db33979 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -122,6 +122,9 @@ Invariants: - Stable ids and short refs are accepted where a surface/pane target is accepted. +- Surface targets also accept `title:`. If exactly one + visible surface has that title, it is selected. If multiple visible surfaces + match, the command fails and lists the matching surface refs. - Short refs currently use cmux-style names for implemented handles: `surface:1`, `pane:2`. - List output defaults to refs; commands that list handles accept diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 41e163f..ff89330 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -163,6 +163,14 @@ function matchesDorPaneTarget(target: string | undefined, surface: DorSurface): return Number.isInteger(numeric) && numeric >= 1 && surface.index === numeric - 1; } +function surfaceTitleTarget(target: string): string | null { + return target.startsWith('title:') ? target.slice('title:'.length) : null; +} + +function renderSurfaceForError(surface: DorSurface): string { + return `${surface.ref} ${JSON.stringify(surface.title)}`; +} + function stringParam(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } @@ -686,15 +694,30 @@ export function Wall({ return id; }, []); - const findVisibleSurface = useCallback(( + const resolveVisibleSurface = useCallback(( api: DockviewApi, target: string | undefined, callerSurfaceId: string | undefined, - ): DorSurface | null => { + ): ParseResult => { const surfaces = buildDorSurfaces(api); const resolvedTarget = target ?? callerSurfaceId ?? 'focused'; - return surfaces.find((surface) => matchesDorPaneTarget(resolvedTarget, surface)) + const titleTarget = surfaceTitleTarget(resolvedTarget); + if (titleTarget !== null) { + const matches = surfaces.filter((surface) => surface.title === titleTarget); + if (matches.length === 1) return { ok: true, value: matches[0] }; + if (matches.length > 1) { + return { + ok: false, + message: `surface target '${resolvedTarget}' matched multiple surfaces: ${matches.map(renderSurfaceForError).join(', ')}`, + }; + } + return { ok: false, message: `surface target '${resolvedTarget}' was not found` }; + } + + const matched = surfaces.find((surface) => matchesDorPaneTarget(resolvedTarget, surface)) ?? (!target && !callerSurfaceId ? (surfaces[0] ?? null) : null); + if (matched) return { ok: true, value: matched }; + return { ok: false, message: `surface '${resolvedTarget}' was not found` }; }, [buildDorSurfaces]); const findSurfaceIdByUserTitle = useCallback((title: string): string | null => { @@ -877,17 +900,17 @@ export function Wall({ // Resolve the split reference surface and its live panel, responding with // the appropriate error and returning null when either is unavailable. const resolveSplitTarget = () => { - const target = findVisibleSurface(api, stringParam(params.surface), detail.surfaceId); - if (!target) { - detail.respond({ ok: false, error: `surface '${stringParam(params.surface) ?? detail.surfaceId ?? 'focused'}' was not found` }); + const target = resolveVisibleSurface(api, stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); return null; } - const panel = api.getPanel(target.id); + const panel = api.getPanel(target.value.id); if (!panel) { - detail.respond({ ok: false, error: `surface '${target.ref}' is not visible` }); + detail.respond({ ok: false, error: `surface '${target.value.ref}' is not visible` }); return null; } - return { target, panel }; + return { target: target.value, panel }; }; if (detail.method === 'surface.list') { @@ -1004,7 +1027,7 @@ export function Wall({ window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, findVisibleSurface, surfaceRefForId]); + }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, resolveVisibleSurface, surfaceRefForId]); const addSplitPanel = useCallback(( id: string | null, From 5f84e40ee49d35bda9954beec958229212890b18 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 29 May 2026 14:56:40 -0700 Subject: [PATCH 2/7] Add dor version command --- .gitignore | 1 + docs/specs/dor-cli.md | 1 + dor/package.json | 1 + dor/src/cli.ts | 4 +++ dor/src/commands/types.ts | 7 ++++++ dor/src/commands/version.ts | 36 +++++++++++++++++++++++++++ dor/test/cli-output.test.mjs | 13 ++++++++++ dor/test/snapshots/help/dor.md | 2 ++ dor/test/snapshots/help/version.md | 19 ++++++++++++++ dor/test/snapshots/version.snap | 5 ++++ scripts/generate-dor-version.mjs | 40 ++++++++++++++++++++++++++++++ 11 files changed, 129 insertions(+) create mode 100644 dor/src/commands/version.ts create mode 100644 dor/test/snapshots/help/version.md create mode 100644 dor/test/snapshots/version.snap create mode 100644 scripts/generate-dor-version.mjs diff --git a/.gitignore b/.gitignore index 618ec51..ca51287 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output dist/ +dor/src/generated-version.ts lib/dist/ *.tsbuildinfo diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index db33979..5d1bc2d 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -157,5 +157,6 @@ from `command-detail`. - `dor split` [impl](../../dor/src/commands/split.ts) [docs](../../dor/test/snapshots/help/split.md) - `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 list-panes` [impl](../../dor/src/commands/list-panes.ts) [docs](../../dor/test/snapshots/help/list-panes.md) - `dor list-pane-surfaces` [impl](../../dor/src/commands/list-pane-surfaces.ts) [docs](../../dor/test/snapshots/help/list-pane-surfaces.md) diff --git a/dor/package.json b/dor/package.json index ff8406c..bbb1824 100644 --- a/dor/package.json +++ b/dor/package.json @@ -12,6 +12,7 @@ "dist" ], "scripts": { + "prebuild": "node ../scripts/generate-dor-version.mjs", "build": "tsc -p tsconfig.json", "test": "pnpm run build && node --test test/*.test.mjs" }, diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 26d49da..7789228 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -10,6 +10,7 @@ import { ensureCommand } from './commands/ensure.js'; import { listPaneSurfacesCommand } from './commands/list-pane-surfaces.js'; import { listPanesCommand } from './commands/list-panes.js'; import { splitCommand } from './commands/split.js'; +import { versionCommand } from './commands/version.js'; import { fail } from './commands/shared.js'; import type { CliEnv, @@ -38,11 +39,13 @@ export type { SplitSurfaceRequest, SplitSurfaceResponse, Surface, + VersionMetadata, } from './commands/types.js'; const COMMANDS = [ splitCommand, ensureCommand, + versionCommand, listPanesCommand, listPaneSurfacesCommand, ] as const satisfies readonly Command[]; @@ -50,6 +53,7 @@ const COMMANDS = [ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, + version: versionCommand.command, 'list-panes': listPanesCommand.command, 'list-pane-surfaces': listPaneSurfacesCommand.command, }; diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index e6ea567..02c1a3b 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -77,6 +77,7 @@ export interface CliEnv { export interface CliOptions { env?: CliEnv; client?: ControlClient; + versionMetadata?: VersionMetadata; } export interface CliResult { @@ -95,6 +96,12 @@ export interface Command { helpPatches?: readonly HelpPatch[]; } +export interface VersionMetadata { + version: string; + commit: string; + commitsSinceVersion: number; +} + export interface HelpPatch { scope: 'root' | 'command-usage' | 'command-detail'; /** Ordered template-pattern find/replace pairs. Tokens: , , . */ diff --git a/dor/src/commands/version.ts b/dor/src/commands/version.ts new file mode 100644 index 0000000..44bbdf9 --- /dev/null +++ b/dor/src/commands/version.ts @@ -0,0 +1,36 @@ +/** Render stamped build metadata for the bundled `dor` CLI. */ + +import { buildCommand } from '@stricli/core'; +import { DOR_VERSION_METADATA } from '../generated-version.js'; +import type { + Command, + DorCommandContext, + VersionMetadata, +} from './types.js'; +import { writeStdout } from './shared.js'; + +export const versionCommand: Command = { + name: 'version', + command: buildCommand<{}, [], DorCommandContext>({ + docs: { + brief: 'Print the dor CLI version.', + fullDescription: `Prints the latest released Dormouse version from CHANGELOG.md, the build commit, and a prerelease-style build suffix when the build contains commits after that version tag. + +Text output: + dor 0.11.0 [1a2b3c4d] (0.11.0+12)`, + }, + parameters: {}, + func: runVersionCommand, + }), +}; + +function runVersionCommand(this: DorCommandContext): void { + writeStdout(this, renderVersion(this.options.versionMetadata ?? DOR_VERSION_METADATA)); +} + +export function renderVersion(metadata: VersionMetadata): string { + const suffix = metadata.commitsSinceVersion > 0 + ? ` (${metadata.version}+${metadata.commitsSinceVersion})` + : ''; + return `dor ${metadata.version} [${metadata.commit}]${suffix}\n`; +} diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 8f4b337..acd1912 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -175,6 +175,19 @@ test('ensure json output', async () => { ); }); +test('version output', async () => { + await snapshot( + 'version', + await runCli(['version'], { + versionMetadata: { + version: '0.12.0', + commit: '6e86b3ba', + commitsSinceVersion: 89, + }, + }), + ); +}); + test('list-panes text output', async () => { await snapshot('list-panes-text', await runCli(['list-panes'], { client: fixtureClient() })); }); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index ab80cec..bcd5eef 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -6,6 +6,7 @@ Invocation: `dor --help` USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref|index] [-- ...] dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ... + dor version dor list-panes [--id-format refs|uuids|both] [--json] dor list-pane-surfaces [--id-format refs|uuids|both] [--json] [--pane id|ref|index] dor --help @@ -19,6 +20,7 @@ FLAGS COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface exists for a user-enforced title. + version Print the dor CLI version. list-panes List visible panes. list-pane-surfaces List surfaces in a pane. diff --git a/dor/test/snapshots/help/version.md b/dor/test/snapshots/help/version.md new file mode 100644 index 0000000..b913b5c --- /dev/null +++ b/dor/test/snapshots/help/version.md @@ -0,0 +1,19 @@ +# dor version + +Invocation: `dor version --help` + +```text +USAGE + dor version + dor version --help + +Prints the latest released Dormouse version from CHANGELOG.md, the build commit, and a prerelease-style build suffix when the build contains commits after that version tag. + +Text output: + dor 0.11.0 [1a2b3c4d] (0.11.0+12) + +FLAGS + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/version.snap b/dor/test/snapshots/version.snap new file mode 100644 index 0000000..fecf270 --- /dev/null +++ b/dor/test/snapshots/version.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +dor 0.12.0 [6e86b3ba] (0.12.0+89) + +stderr: diff --git a/scripts/generate-dor-version.mjs b/scripts/generate-dor-version.mjs new file mode 100644 index 0000000..a6a6e07 --- /dev/null +++ b/scripts/generate-dor-version.mjs @@ -0,0 +1,40 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = resolve(dirname(scriptPath), '..'); + +const changelog = await readFile(resolve(repoRoot, 'CHANGELOG.md'), 'utf8'); +const version = latestChangelogVersion(changelog); +const commit = git(['rev-parse', '--short=8', 'HEAD']) || 'unknown'; +const commitsSinceVersion = Number(git(['rev-list', '--count', `v${version}..HEAD`]) || '0'); +const outPath = resolve(repoRoot, 'dor/src/generated-version.ts'); + +await mkdir(dirname(outPath), { recursive: true }); +await writeFile( + outPath, + `// Generated by scripts/generate-dor-version.mjs. Do not edit.\n` + + `export const DOR_VERSION_METADATA = ${JSON.stringify({ + version, + commit, + commitsSinceVersion: Number.isFinite(commitsSinceVersion) ? commitsSinceVersion : 0, + }, null, 2)} as const;\n`, +); + +function latestChangelogVersion(input) { + for (const line of input.split('\n')) { + const match = /^## \[([0-9]+\.[0-9]+\.[0-9]+)\]/.exec(line); + if (match) return match[1]; + } + return '0.0.0'; +} + +function git(args) { + try { + return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} From 95aac9895a2ad1379a809bb184ab9293bf0c5814 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 29 May 2026 14:59:58 -0700 Subject: [PATCH 3/7] Add dor send command --- docs/specs/dor-cli.md | 1 + dor/src/cli.ts | 5 + dor/src/commands/send.ts | 224 +++++++++++++++++++ dor/src/commands/types.ts | 15 ++ dor/src/control-client.ts | 6 + dor/src/dor.ts | 20 +- dor/test/cli-output.test.mjs | 78 +++++++ dor/test/snapshots/help/dor.md | 2 + dor/test/snapshots/help/send.md | 42 ++++ dor/test/snapshots/send-missing-input.snap | 5 + dor/test/snapshots/send-text.snap | 5 + dor/test/snapshots/send-unsupported-key.snap | 5 + lib/src/components/Wall.tsx | 28 ++- 13 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 dor/src/commands/send.ts create mode 100644 dor/test/snapshots/help/send.md create mode 100644 dor/test/snapshots/send-missing-input.snap create mode 100644 dor/test/snapshots/send-text.snap create mode 100644 dor/test/snapshots/send-unsupported-key.snap diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 5d1bc2d..8f57f4a 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -158,5 +158,6 @@ from `command-detail`. - `dor split` [impl](../../dor/src/commands/split.ts) [docs](../../dor/test/snapshots/help/split.md) - `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) - `dor list-panes` [impl](../../dor/src/commands/list-panes.ts) [docs](../../dor/test/snapshots/help/list-panes.md) - `dor list-pane-surfaces` [impl](../../dor/src/commands/list-pane-surfaces.ts) [docs](../../dor/test/snapshots/help/list-pane-surfaces.md) diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 7789228..73cd661 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -9,6 +9,7 @@ import { import { ensureCommand } from './commands/ensure.js'; import { listPaneSurfacesCommand } from './commands/list-pane-surfaces.js'; import { listPanesCommand } from './commands/list-panes.js'; +import { sendCommand } from './commands/send.js'; import { splitCommand } from './commands/split.js'; import { versionCommand } from './commands/version.js'; import { fail } from './commands/shared.js'; @@ -35,6 +36,8 @@ export type { ListSurfacesRequest, ListSurfacesResponse, ResolvedSplitDirection, + SendSurfaceRequest, + SendSurfaceResponse, SplitDirection, SplitSurfaceRequest, SplitSurfaceResponse, @@ -46,6 +49,7 @@ const COMMANDS = [ splitCommand, ensureCommand, versionCommand, + sendCommand, listPanesCommand, listPaneSurfacesCommand, ] as const satisfies readonly Command[]; @@ -54,6 +58,7 @@ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, version: versionCommand.command, + send: sendCommand.command, 'list-panes': listPanesCommand.command, 'list-pane-surfaces': listPaneSurfacesCommand.command, }; diff --git a/dor/src/commands/send.ts b/dor/src/commands/send.ts new file mode 100644 index 0000000..2081bd5 --- /dev/null +++ b/dor/src/commands/send.ts @@ -0,0 +1,224 @@ +/** Private `surface.send` command wiring and input-byte rendering. */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + ParseResult, + SendSurfaceResponse, +} from './types.js'; +import { + resolveControlClient, + stringParser, + writeStdout, +} from './shared.js'; + +interface SendFlags { + readonly key?: string; + readonly raw?: boolean; + readonly sequence?: string; + readonly stdin?: boolean; + readonly surface?: string; + readonly text?: string; +} + +type SendInput = + | { kind: 'text'; text: string } + | { kind: 'key'; key: string }; + +export const sendCommand: Command = { + name: 'send', + command: buildCommand({ + docs: { + brief: 'Send text or key input to a terminal surface.', + fullDescription: `By default, a positional argument is sent as text. Special keys must be sent with --key so values like "enter" are never confused with literal text. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Exactly one input source is required: TEXT, --text, --key, --stdin, or --sequence. + +Text input interprets backslash escapes for \\n, \\r, \\t, and \\\\ unless --raw is set. Prefer --key enter when submitting a prompt. + +Supported keys: enter, escape, esc, tab, backspace, delete, up, down, left, right, ctrl-a through ctrl-z. + +Sequence input is an ordered JSON array of {"text":"..."} and {"key":"..."} objects. + +Examples: + dor send "echo hello" + dor send --key enter + dor send --surface surface:3 --key ctrl-c + cat script.sh | dor send --surface surface:3 --stdin + dor send --surface surface:3 --sequence '[{"text":"npm test"},{"key":"enter"}]'`, + }, + parameters: { + flags: { + key: { kind: 'parsed', parse: stringParser, brief: 'Send a named key or chord.', optional: true }, + raw: { kind: 'boolean', brief: 'Do not interpret backslash escapes in text input.', optional: true, withNegated: false }, + sequence: { kind: 'parsed', parse: stringParser, brief: 'Send an ordered JSON sequence of text and key events.', optional: true, placeholder: 'json' }, + stdin: { kind: 'boolean', brief: 'Read text from standard input and send it as text.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Target surface.', optional: true, placeholder: 'id|ref|index' }, + text: { kind: 'parsed', parse: stringParser, brief: 'Send literal text.', optional: true }, + }, + positional: { + kind: 'tuple', + parameters: [ + { parse: stringParser, brief: 'Text to send.', optional: true, placeholder: 'text' }, + ], + }, + }, + func: runSendCommand, + }), +}; + +async function runSendCommand(this: DorCommandContext, flags: SendFlags, text?: string): Promise { + const inputs = await collectSendInputs(flags, text, this.options.readStdin); + if (!inputs.ok) return new Error(inputs.message); + + const encoded = encodeSendInputs(inputs.value, flags.raw === true); + if (!encoded.ok) return new Error(encoded.message); + if (encoded.value.input.length === 0) return new Error('input cannot be empty'); + + const clientResult = resolveControlClient(this.options); + if (!clientResult.ok) return new Error(clientResult.message); + + try { + const response = await clientResult.value.sendSurface({ + surface: flags.surface, + input: encoded.value.input, + inputCount: encoded.value.inputCount, + }); + writeStdout(this, renderSendResponse(response)); + return undefined; + } catch (error) { + return new Error(error instanceof Error ? error.message : String(error)); + } +} + +async function collectSendInputs( + flags: SendFlags, + positionalText: string | undefined, + readStdin: (() => Promise) | undefined, +): Promise> { + const sources = [ + positionalText !== undefined, + flags.text !== undefined, + flags.key !== undefined, + flags.stdin === true, + flags.sequence !== undefined, + ].filter(Boolean).length; + + if (sources !== 1) { + return { ok: false, message: 'dor send requires exactly one input source' }; + } + + if (positionalText !== undefined) return { ok: true, value: [{ kind: 'text', text: positionalText }] }; + if (flags.text !== undefined) return { ok: true, value: [{ kind: 'text', text: flags.text }] }; + if (flags.key !== undefined) return { ok: true, value: [{ kind: 'key', key: flags.key }] }; + if (flags.stdin === true) { + if (!readStdin) return { ok: false, message: 'stdin is not available' }; + return { ok: true, value: [{ kind: 'text', text: await readStdin() }] }; + } + return parseSendSequence(flags.sequence ?? ''); +} + +function parseSendSequence(input: string): ParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (error) { + return { ok: false, message: `invalid --sequence JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + + if (!Array.isArray(parsed)) { + return { ok: false, message: '--sequence must be a JSON array' }; + } + + const inputs: SendInput[] = []; + for (const [index, item] of parsed.entries()) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return { ok: false, message: `--sequence item ${index + 1} must be an object` }; + } + const text = 'text' in item ? (item as { text: unknown }).text : undefined; + const key = 'key' in item ? (item as { key: unknown }).key : undefined; + if ((text === undefined) === (key === undefined)) { + return { ok: false, message: `--sequence item ${index + 1} must contain exactly one of text or key` }; + } + if (text !== undefined) { + if (typeof text !== 'string') return { ok: false, message: `--sequence item ${index + 1} text must be a string` }; + inputs.push({ kind: 'text', text }); + } else { + if (typeof key !== 'string') return { ok: false, message: `--sequence item ${index + 1} key must be a string` }; + inputs.push({ kind: 'key', key }); + } + } + + return { ok: true, value: inputs }; +} + +function encodeSendInputs(inputs: SendInput[], raw: boolean): ParseResult<{ input: string; inputCount: number }> { + let input = ''; + for (const item of inputs) { + if (item.kind === 'text') { + input += raw ? item.text : interpretTextEscapes(item.text); + } else { + const key = encodeKey(item.key); + if (!key.ok) return key; + input += key.value; + } + } + return { ok: true, value: { input, inputCount: inputs.length } }; +} + +function interpretTextEscapes(input: string): string { + return input.replace(/\\([nrt\\])/g, (_match, escape: string) => { + switch (escape) { + case 'n': + return '\n'; + case 'r': + return '\r'; + case 't': + return '\t'; + case '\\': + return '\\'; + default: + return escape; + } + }); +} + +function encodeKey(input: string): ParseResult { + const key = input.trim().toLowerCase(); + switch (key) { + case 'enter': + return { ok: true, value: '\r' }; + case 'escape': + case 'esc': + return { ok: true, value: '\x1b' }; + case 'tab': + return { ok: true, value: '\t' }; + case 'backspace': + return { ok: true, value: '\x7f' }; + case 'delete': + return { ok: true, value: '\x1b[3~' }; + case 'up': + return { ok: true, value: '\x1b[A' }; + case 'down': + return { ok: true, value: '\x1b[B' }; + case 'right': + return { ok: true, value: '\x1b[C' }; + case 'left': + return { ok: true, value: '\x1b[D' }; + } + + const ctrl = /^ctrl-([a-z])$/.exec(key); + if (ctrl) { + return { ok: true, value: String.fromCharCode(ctrl[1].charCodeAt(0) - 96) }; + } + + return { ok: false, message: `unsupported key '${input}'` }; +} + +function renderSendResponse(response: SendSurfaceResponse): string { + const noun = response.inputCount === 1 ? 'input' : 'inputs'; + return `${response.status} ${response.surfaceRef} [${response.inputCount} ${noun}]\n`; +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 02c1a3b..b60ed47 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -64,10 +64,24 @@ export interface EnsureSurfaceResponse { minimized: boolean; } +export interface SendSurfaceRequest { + surface?: string; + input: string; + inputCount: number; +} + +export interface SendSurfaceResponse { + status: 'sent'; + surfaceId?: string; + surfaceRef: string; + inputCount: number; +} + export interface ControlClient { listSurfaces(request: ListSurfacesRequest): Promise; splitSurface(request: SplitSurfaceRequest): Promise; ensureSurface(request: EnsureSurfaceRequest): Promise; + sendSurface(request: SendSurfaceRequest): Promise; } export interface CliEnv { @@ -77,6 +91,7 @@ export interface CliEnv { export interface CliOptions { env?: CliEnv; client?: ControlClient; + readStdin?: () => Promise; versionMetadata?: VersionMetadata; } diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index c420c13..b615df2 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -5,6 +5,8 @@ import type { EnsureSurfaceResponse, ListSurfacesRequest, ListSurfacesResponse, + SendSurfaceRequest, + SendSurfaceResponse, SplitSurfaceRequest, SplitSurfaceResponse, } from './commands/types.js'; @@ -48,6 +50,10 @@ export class SocketControlClient implements ControlClient { return this.request('surface.ensure', request); } + sendSurface(request: SendSurfaceRequest): Promise { + return this.request('surface.send', request); + } + private request(method: string, params: unknown): Promise { const requestId = `dor-${this.idBase}-${++this.nextRequestId}`; return new Promise((resolve, reject) => { diff --git a/dor/src/dor.ts b/dor/src/dor.ts index 005f6c0..dfc58b7 100644 --- a/dor/src/dor.ts +++ b/dor/src/dor.ts @@ -8,11 +8,18 @@ type ProcessLike = { exitCode?: number; stdout: { write(chunk: string): void }; stderr: { write(chunk: string): void }; + stdin: { + setEncoding?(encoding: string): void; + on(event: 'data', listener: (chunk: string) => void): void; + on(event: 'end', listener: () => void): void; + on(event: 'error', listener: (error: Error) => void): void; + resume?(): void; + }; }; declare const process: ProcessLike; -runCli(process.argv.slice(2), { env: process.env }).then( +runCli(process.argv.slice(2), { env: process.env, readStdin }).then( (result) => { process.stdout.write(result.stdout); process.stderr.write(result.stderr); @@ -23,3 +30,14 @@ runCli(process.argv.slice(2), { env: process.env }).then( process.exitCode = 1; }, ); + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + const chunks: string[] = []; + process.stdin.setEncoding?.('utf8'); + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => resolve(chunks.join(''))); + process.stdin.on('error', reject); + process.stdin.resume?.(); + }); +} diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index acd1912..167580d 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -80,6 +80,17 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { minimized: request.minimized, }; }, + async sendSurface(request) { + this.requests.push({ method: 'sendSurface', request }); + return { + status: 'sent', + surfaceId: request.surface === 'surface:2' + ? '22222222-2222-4222-8222-222222222222' + : '11111111-1111-4111-8111-111111111111', + surfaceRef: request.surface ?? 'surface:1', + inputCount: request.inputCount, + }; + }, }; } @@ -188,6 +199,73 @@ test('version output', async () => { ); }); +test('send text output', async () => { + await snapshot( + 'send-text', + await runCli(['send', '--surface', 'surface:2', 'echo hello\\n'], { client: fixtureClient() }), + ); +}); + +test('send sends escaped text to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--text', 'echo hello\\n'], { client }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: undefined, + input: 'echo hello\n', + inputCount: 1, + }, + }]); +}); + +test('send sends key input to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--surface', 'surface:2', '--key', 'ctrl-c'], { client }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: 'surface:2', + input: '\x03', + inputCount: 1, + }, + }]); +}); + +test('send sequence sends ordered input to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--sequence', '[{"text":"npm test"},{"key":"enter"}]'], { client }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: undefined, + input: 'npm test\r', + inputCount: 2, + }, + }]); +}); + +test('send stdin sends standard input to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--stdin'], { client, readStdin: async () => 'cat from stdin\n' }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: undefined, + input: 'cat from stdin\n', + inputCount: 1, + }, + }]); +}); + +test('send missing input output', async () => { + await snapshot('send-missing-input', await runCli(['send'], { client: fixtureClient() })); +}); + +test('send unsupported key output', async () => { + await snapshot('send-unsupported-key', await runCli(['send', '--key', 'cmd-k'], { client: fixtureClient() })); +}); + test('list-panes text output', async () => { await snapshot('list-panes-text', await runCli(['list-panes'], { client: fixtureClient() })); }); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index bcd5eef..1728db5 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -7,6 +7,7 @@ USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref|index] [-- ...] dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ... dor version + dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] dor list-panes [--id-format refs|uuids|both] [--json] dor list-pane-surfaces [--id-format refs|uuids|both] [--json] [--pane id|ref|index] dor --help @@ -21,6 +22,7 @@ COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface exists for a user-enforced title. version Print the dor CLI version. + send Send text or key input to a terminal surface. list-panes List visible panes. list-pane-surfaces List surfaces in a pane. diff --git a/dor/test/snapshots/help/send.md b/dor/test/snapshots/help/send.md new file mode 100644 index 0000000..d6ed815 --- /dev/null +++ b/dor/test/snapshots/help/send.md @@ -0,0 +1,42 @@ +# dor send + +Invocation: `dor send --help` + +```text +USAGE + dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] + dor send --help + +By default, a positional argument is sent as text. Special keys must be sent with --key so values like "enter" are never confused with literal text. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Exactly one input source is required: TEXT, --text, --key, --stdin, or --sequence. + +Text input interprets backslash escapes for \n, \r, \t, and \\ unless --raw is set. Prefer --key enter when submitting a prompt. + +Supported keys: enter, escape, esc, tab, backspace, delete, up, down, left, right, ctrl-a through ctrl-z. + +Sequence input is an ordered JSON array of {"text":"..."} and {"key":"..."} objects. + +Examples: + dor send "echo hello" + dor send --key enter + dor send --surface surface:3 --key ctrl-c + cat script.sh | dor send --surface surface:3 --stdin + dor send --surface surface:3 --sequence '[{"text":"npm test"},{"key":"enter"}]' + +FLAGS + [--key] Send a named key or chord. + [--raw] Do not interpret backslash escapes in text input. + [--sequence] Send an ordered JSON sequence of text and key events. + [--stdin] Read text from standard input and send it as text. + [--surface] Target surface. + [--text] Send literal text. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +ARGUMENTS + [text] Text to send. + +``` diff --git a/dor/test/snapshots/send-missing-input.snap b/dor/test/snapshots/send-missing-input.snap new file mode 100644 index 0000000..9ce3d5d --- /dev/null +++ b/dor/test/snapshots/send-missing-input.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor send requires exactly one input source diff --git a/dor/test/snapshots/send-text.snap b/dor/test/snapshots/send-text.snap new file mode 100644 index 0000000..529b150 --- /dev/null +++ b/dor/test/snapshots/send-text.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +sent surface:2 [1 input] + +stderr: diff --git a/dor/test/snapshots/send-unsupported-key.snap b/dor/test/snapshots/send-unsupported-key.snap new file mode 100644 index 0000000..12fd82a --- /dev/null +++ b/dor/test/snapshots/send-unsupported-key.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: unsupported key 'cmd-k' diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index ff89330..8d79529 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -36,7 +36,7 @@ import { resolveDisplayPrimary, } from '../lib/terminal-state'; import { orchestrateKill } from '../lib/kill-animation'; -import { PLATFORM_STRING } from '../lib/platform'; +import { getPlatform, PLATFORM_STRING } from '../lib/platform'; import type { DorControlRequestPayload, DorControlResult } from 'dor/protocol'; import type { Surface as DorSurface, @@ -88,6 +88,8 @@ type ShellSpawnNoticeState = { type DorControlParams = { command?: unknown; direction?: unknown; + input?: unknown; + inputCount?: unknown; minimized?: unknown; pane?: string; surface?: unknown; @@ -1022,6 +1024,30 @@ export function Wall({ return; } + if (detail.method === 'surface.send') { + const input = stringParam(params.input); + if (input === undefined) { + detail.respond({ ok: false, error: 'input is required' }); + return; + } + const target = resolveVisibleSurface(api, stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return; + } + getPlatform().writePty(target.value.id, input); + detail.respond({ + ok: true, + result: { + status: 'sent', + surfaceId: target.value.id, + surfaceRef: target.value.ref, + inputCount: typeof params.inputCount === 'number' ? params.inputCount : 1, + }, + }); + return; + } + detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); }; From 8f84c0bf92abcd6686cb7a5574cc9a5156fdf66c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 29 May 2026 15:02:19 -0700 Subject: [PATCH 4/7] Add dor read command --- docs/specs/dor-cli.md | 1 + dor/src/cli.ts | 5 ++ dor/src/commands/read.ts | 89 ++++++++++++++++++++++ dor/src/commands/types.ts | 14 ++++ dor/src/control-client.ts | 6 ++ dor/test/cli-output.test.mjs | 43 +++++++++++ dor/test/snapshots/help/dor.md | 2 + dor/test/snapshots/help/read.md | 32 ++++++++ dor/test/snapshots/read-invalid-lines.snap | 5 ++ dor/test/snapshots/read-json.snap | 10 +++ dor/test/snapshots/read-text.snap | 5 ++ lib/src/components/Wall.tsx | 53 ++++++++++++- 12 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 dor/src/commands/read.ts create mode 100644 dor/test/snapshots/help/read.md create mode 100644 dor/test/snapshots/read-invalid-lines.snap create mode 100644 dor/test/snapshots/read-json.snap create mode 100644 dor/test/snapshots/read-text.snap diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 8f57f4a..ecf767d 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -159,5 +159,6 @@ from `command-detail`. - `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) +- `dor read` [impl](../../dor/src/commands/read.ts) [docs](../../dor/test/snapshots/help/read.md) - `dor list-panes` [impl](../../dor/src/commands/list-panes.ts) [docs](../../dor/test/snapshots/help/list-panes.md) - `dor list-pane-surfaces` [impl](../../dor/src/commands/list-pane-surfaces.ts) [docs](../../dor/test/snapshots/help/list-pane-surfaces.md) diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 73cd661..459ba9b 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -9,6 +9,7 @@ import { import { ensureCommand } from './commands/ensure.js'; import { listPaneSurfacesCommand } from './commands/list-pane-surfaces.js'; import { listPanesCommand } from './commands/list-panes.js'; +import { readCommand } from './commands/read.js'; import { sendCommand } from './commands/send.js'; import { splitCommand } from './commands/split.js'; import { versionCommand } from './commands/version.js'; @@ -35,6 +36,8 @@ export type { IdFormat, ListSurfacesRequest, ListSurfacesResponse, + ReadSurfaceRequest, + ReadSurfaceResponse, ResolvedSplitDirection, SendSurfaceRequest, SendSurfaceResponse, @@ -50,6 +53,7 @@ const COMMANDS = [ ensureCommand, versionCommand, sendCommand, + readCommand, listPanesCommand, listPaneSurfacesCommand, ] as const satisfies readonly Command[]; @@ -59,6 +63,7 @@ const ROUTES = { ensure: ensureCommand.command, version: versionCommand.command, send: sendCommand.command, + read: readCommand.command, 'list-panes': listPanesCommand.command, 'list-pane-surfaces': listPaneSurfacesCommand.command, }; diff --git a/dor/src/commands/read.ts b/dor/src/commands/read.ts new file mode 100644 index 0000000..33a53ee --- /dev/null +++ b/dor/src/commands/read.ts @@ -0,0 +1,89 @@ +/** Private `surface.read` command wiring and response rendering. */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + ReadSurfaceResponse, +} from './types.js'; +import { + resolveControlClient, + stringParser, + writeStdout, +} from './shared.js'; + +interface ReadFlags { + readonly json?: boolean; + readonly lines?: number; + readonly scrollback?: boolean; + readonly surface?: string; +} + +export const readCommand: Command = { + name: 'read', + command: buildCommand({ + docs: { + brief: 'Read terminal text from a surface.', + fullDescription: `By default, reads the visible screen text from the target surface. Use --scrollback to include terminal history, and --lines to limit how much text is returned. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Text mode prints terminal text directly. + +JSON output: + { + "workspace_ref": "workspace:1", + "surface_id": "...", + "surface_ref": "surface:3", + "text": "..." + }`, + }, + parameters: { + flags: { + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + lines: { kind: 'parsed', parse: parseLineCount, brief: 'Maximum number of lines to return.', optional: true, placeholder: 'count' }, + scrollback: { kind: 'boolean', brief: 'Include terminal scrollback/history instead of only the visible screen.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to read.', optional: true, placeholder: 'id|ref|index' }, + }, + }, + func: runReadCommand, + }), +}; + +async function runReadCommand(this: DorCommandContext, flags: ReadFlags): Promise { + const clientResult = resolveControlClient(this.options); + if (!clientResult.ok) return new Error(clientResult.message); + + try { + const response = await clientResult.value.readSurface({ + ...(flags.lines !== undefined ? { lines: flags.lines } : {}), + scrollback: flags.scrollback === true, + surface: flags.surface, + }); + writeStdout(this, renderReadResponse(response, flags.json === true)); + return undefined; + } catch (error) { + return new Error(error instanceof Error ? error.message : String(error)); + } +} + +function parseLineCount(input: string): number { + const value = Number(input); + if (!Number.isInteger(value) || value <= 0) { + throw new SyntaxError(`invalid --lines '${input}'`); + } + return value; +} + +function renderReadResponse(response: ReadSurfaceResponse, json: boolean): string { + if (json) { + return `${JSON.stringify({ + workspace_ref: response.workspaceRef, + ...(response.surfaceId ? { surface_id: response.surfaceId } : {}), + surface_ref: response.surfaceRef, + text: response.text, + }, null, 2)}\n`; + } + + return response.text; +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index b60ed47..bec9040 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -77,11 +77,25 @@ export interface SendSurfaceResponse { inputCount: number; } +export interface ReadSurfaceRequest { + lines?: number; + scrollback: boolean; + surface?: string; +} + +export interface ReadSurfaceResponse { + workspaceRef: string; + surfaceId?: string; + surfaceRef: string; + text: string; +} + export interface ControlClient { listSurfaces(request: ListSurfacesRequest): Promise; splitSurface(request: SplitSurfaceRequest): Promise; ensureSurface(request: EnsureSurfaceRequest): Promise; sendSurface(request: SendSurfaceRequest): Promise; + readSurface(request: ReadSurfaceRequest): Promise; } export interface CliEnv { diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index b615df2..b7ed454 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -5,6 +5,8 @@ import type { EnsureSurfaceResponse, ListSurfacesRequest, ListSurfacesResponse, + ReadSurfaceRequest, + ReadSurfaceResponse, SendSurfaceRequest, SendSurfaceResponse, SplitSurfaceRequest, @@ -54,6 +56,10 @@ export class SocketControlClient implements ControlClient { return this.request('surface.send', request); } + readSurface(request: ReadSurfaceRequest): Promise { + return this.request('surface.read', request); + } + private request(method: string, params: unknown): Promise { const requestId = `dor-${this.idBase}-${++this.nextRequestId}`; return new Promise((resolve, reject) => { diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 167580d..22a26d1 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -91,6 +91,21 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { inputCount: request.inputCount, }; }, + async readSurface(request) { + this.requests.push({ method: 'readSurface', request }); + const text = request.scrollback + ? 'first line\nsecond line\nthird line\nfourth line' + : 'visible one\nvisible two'; + const limited = request.lines ? text.split('\n').slice(-request.lines).join('\n') : text; + return { + workspaceRef: 'workspace:1', + surfaceId: request.surface === 'surface:2' + ? '22222222-2222-4222-8222-222222222222' + : '11111111-1111-4111-8111-111111111111', + surfaceRef: request.surface ?? 'surface:1', + text: limited, + }; + }, }; } @@ -266,6 +281,34 @@ test('send unsupported key output', async () => { await snapshot('send-unsupported-key', await runCli(['send', '--key', 'cmd-k'], { client: fixtureClient() })); }); +test('read text output', async () => { + await snapshot('read-text', await runCli(['read'], { client: fixtureClient() })); +}); + +test('read sends request to the host', async () => { + const client = fixtureClient(); + await runCli(['read', '--surface', 'title:repo "watch"', '--scrollback', '--lines', '2'], { client }); + assert.deepEqual(client.requests, [{ + method: 'readSurface', + request: { + lines: 2, + scrollback: true, + surface: 'title:repo "watch"', + }, + }]); +}); + +test('read json output', async () => { + await snapshot( + 'read-json', + await runCli(['read', '--json', '--surface', 'surface:2', '--scrollback', '--lines', '2'], { client: fixtureClient() }), + ); +}); + +test('read invalid lines output', async () => { + await snapshot('read-invalid-lines', await runCli(['read', '--lines', '0'], { client: fixtureClient() })); +}); + test('list-panes text output', async () => { await snapshot('list-panes-text', await runCli(['list-panes'], { client: fixtureClient() })); }); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 1728db5..00ab781 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -8,6 +8,7 @@ USAGE dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ... dor version dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] + dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index] dor list-panes [--id-format refs|uuids|both] [--json] dor list-pane-surfaces [--id-format refs|uuids|both] [--json] [--pane id|ref|index] dor --help @@ -23,6 +24,7 @@ COMMANDS ensure Ensure one surface exists for a user-enforced title. version Print the dor CLI version. send Send text or key input to a terminal surface. + read Read terminal text from a surface. list-panes List visible panes. list-pane-surfaces List surfaces in a pane. diff --git a/dor/test/snapshots/help/read.md b/dor/test/snapshots/help/read.md new file mode 100644 index 0000000..3480a41 --- /dev/null +++ b/dor/test/snapshots/help/read.md @@ -0,0 +1,32 @@ +# dor read + +Invocation: `dor read --help` + +```text +USAGE + dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index] + dor read --help + +By default, reads the visible screen text from the target surface. Use --scrollback to include terminal history, and --lines to limit how much text is returned. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Text mode prints terminal text directly. + +JSON output: + { + "workspace_ref": "workspace:1", + "surface_id": "...", + "surface_ref": "surface:3", + "text": "..." + } + +FLAGS + [--json] Print JSON output. + [--lines] Maximum number of lines to return. + [--scrollback] Include terminal scrollback/history instead of only the visible screen. + [--surface] Surface to read. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/read-invalid-lines.snap b/dor/test/snapshots/read-invalid-lines.snap new file mode 100644 index 0000000..f3571d2 --- /dev/null +++ b/dor/test/snapshots/read-invalid-lines.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: Failed to parse "0" for lines: invalid --lines '0' diff --git a/dor/test/snapshots/read-json.snap b/dor/test/snapshots/read-json.snap new file mode 100644 index 0000000..f880371 --- /dev/null +++ b/dor/test/snapshots/read-json.snap @@ -0,0 +1,10 @@ +exitCode: 0 +stdout: +{ + "workspace_ref": "workspace:1", + "surface_id": "22222222-2222-4222-8222-222222222222", + "surface_ref": "surface:2", + "text": "third line\nfourth line" +} + +stderr: diff --git a/dor/test/snapshots/read-text.snap b/dor/test/snapshots/read-text.snap new file mode 100644 index 0000000..2ee5598 --- /dev/null +++ b/dor/test/snapshots/read-text.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +visible one +visible two +stderr: diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 8d79529..4abdee3 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -24,6 +24,7 @@ import { getActivitySnapshot, isUntouched, getOrCreateTerminal, + getTerminalInstance, isReservedUserTitle, setTerminalUserTitle, UNNAMED_PANEL_TITLE, @@ -90,12 +91,14 @@ type DorControlParams = { direction?: unknown; input?: unknown; inputCount?: unknown; + lines?: unknown; minimized?: unknown; pane?: string; surface?: unknown; title?: unknown; workspace?: string; window?: string; + scrollback?: unknown; }; // The webview view of a control request: the shared wire payload, but with @@ -181,6 +184,31 @@ function booleanParam(value: unknown): boolean { return value === true; } +function numberParam(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function limitLines(text: string, lines: number | undefined): string { + if (lines === undefined) return text; + const parts = text.split('\n'); + return parts.slice(-lines).join('\n'); +} + +function readVisibleSurfaceText(surfaceId: string, lines: number | undefined): string { + const terminal = getTerminalInstance(surfaceId); + if (!terminal) return ''; + + const buffer = terminal.buffer.active; + const start = Math.max(0, buffer.viewportY); + const end = start + terminal.rows; + const visibleLines: string[] = []; + for (let row = start; row < end; row += 1) { + visibleLines.push(buffer.getLine(row)?.translateToString(true) ?? ''); + } + + return limitLines(visibleLines.join('\n').replace(/\n+$/, ''), lines); +} + function parseDorSplitDirection(value: unknown): DorSplitDirection | null { if (value === undefined || value === null) return 'auto'; if (value === 'left' || value === 'right' || value === 'up' || value === 'down' || value === 'auto') return value; @@ -879,7 +907,7 @@ export function Wall({ }, [generatePaneId, selectPane, showShellSpawnNotice]); useEffect(() => { - const handler = (event: Event) => { + const handler = async (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail) return; @@ -1048,6 +1076,29 @@ export function Wall({ return; } + if (detail.method === 'surface.read') { + const target = resolveVisibleSurface(api, stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return; + } + const lines = numberParam(params.lines); + const scrollback = booleanParam(params.scrollback); + const text = scrollback + ? limitLines((await getPlatform().getScrollback(target.value.id)) ?? '', lines) + : readVisibleSurfaceText(target.value.id, lines); + detail.respond({ + ok: true, + result: { + workspaceRef: 'workspace:1', + surfaceId: target.value.id, + surfaceRef: target.value.ref, + text, + }, + }); + return; + } + detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); }; From 27e21b72a943cd4667b85c559668f9fed28441a8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 29 May 2026 15:05:01 -0700 Subject: [PATCH 5/7] Add dor kill command --- docs/specs/dor-cli.md | 1 + dor/src/cli.ts | 6 + dor/src/commands/kill.ts | 110 ++++++++++++++++++ dor/src/commands/types.ts | 17 +++ dor/src/control-client.ts | 6 + dor/test/cli-output.test.mjs | 40 +++++++ dor/test/snapshots/help/dor.md | 2 + dor/test/snapshots/help/kill.md | 29 +++++ .../snapshots/kill-missing-confirmation.snap | 5 + .../snapshots/kill-short-confirm-if-read.snap | 5 + dor/test/snapshots/kill-text.snap | 5 + lib/src/components/Wall.tsx | 56 ++++++++- 12 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 dor/src/commands/kill.ts create mode 100644 dor/test/snapshots/help/kill.md create mode 100644 dor/test/snapshots/kill-missing-confirmation.snap create mode 100644 dor/test/snapshots/kill-short-confirm-if-read.snap create mode 100644 dor/test/snapshots/kill-text.snap diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index ecf767d..fc92384 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -160,5 +160,6 @@ from `command-detail`. - `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) - `dor read` [impl](../../dor/src/commands/read.ts) [docs](../../dor/test/snapshots/help/read.md) +- `dor kill` [impl](../../dor/src/commands/kill.ts) [docs](../../dor/test/snapshots/help/kill.md) - `dor list-panes` [impl](../../dor/src/commands/list-panes.ts) [docs](../../dor/test/snapshots/help/list-panes.md) - `dor list-pane-surfaces` [impl](../../dor/src/commands/list-pane-surfaces.ts) [docs](../../dor/test/snapshots/help/list-pane-surfaces.md) diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 459ba9b..7ff13e2 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -7,6 +7,7 @@ import { type StricliProcess, } from '@stricli/core'; import { ensureCommand } from './commands/ensure.js'; +import { killCommand } from './commands/kill.js'; import { listPaneSurfacesCommand } from './commands/list-pane-surfaces.js'; import { listPanesCommand } from './commands/list-panes.js'; import { readCommand } from './commands/read.js'; @@ -34,6 +35,9 @@ export type { EnsureSurfaceRequest, EnsureSurfaceResponse, IdFormat, + KillSurfaceConfirmation, + KillSurfaceRequest, + KillSurfaceResponse, ListSurfacesRequest, ListSurfacesResponse, ReadSurfaceRequest, @@ -54,6 +58,7 @@ const COMMANDS = [ versionCommand, sendCommand, readCommand, + killCommand, listPanesCommand, listPaneSurfacesCommand, ] as const satisfies readonly Command[]; @@ -64,6 +69,7 @@ const ROUTES = { version: versionCommand.command, send: sendCommand.command, read: readCommand.command, + kill: killCommand.command, 'list-panes': listPanesCommand.command, 'list-pane-surfaces': listPaneSurfacesCommand.command, }; diff --git a/dor/src/commands/kill.ts b/dor/src/commands/kill.ts new file mode 100644 index 0000000..57a617f --- /dev/null +++ b/dor/src/commands/kill.ts @@ -0,0 +1,110 @@ +/** Private `surface.kill` command wiring and confirmation validation. */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + KillSurfaceConfirmation, + KillSurfaceResponse, + ParseResult, +} from './types.js'; +import { + resolveControlClient, + stringParser, + writeStdout, +} from './shared.js'; + +interface KillFlags { + readonly confirmAwaitUser?: boolean; + readonly confirmDangerously?: boolean; + readonly confirmIfRead?: string; + readonly surface: string; +} + +export const killCommand: Command = { + name: 'kill', + helpPatches: [ + { + scope: 'root', + findReplace: [ + ' dor kill [--confirm-await-user] [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', + ' dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously]', + ], + }, + { + scope: 'command-usage', + findReplace: [ + ' dor kill [--confirm-await-user] [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', + ' dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously]', + ], + }, + ], + command: buildCommand({ + docs: { + brief: 'Kill a terminal surface.', + fullDescription: `Kills a terminal surface. One confirmation mode is required. + +--confirm-await-user asks Dormouse to prompt before killing. + +--confirm-if-read kills only if dor read --surface would return visible text containing the provided text. The text must contain at least 4 non-whitespace characters. + +--confirm-dangerously kills without further confirmation. Use only when automation has already validated the target. + +Text output: + killed surface:3`, + }, + parameters: { + flags: { + confirmAwaitUser: { kind: 'boolean', brief: 'Ask Dormouse to prompt before killing.', optional: true, withNegated: false }, + confirmDangerously: { kind: 'boolean', brief: 'Kill without further confirmation.', optional: true, withNegated: false }, + confirmIfRead: { kind: 'parsed', parse: stringParser, brief: 'Kill only if dor read contains this text.', optional: true, placeholder: 'text' }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to kill.', placeholder: 'id|ref|index' }, + }, + }, + func: runKillCommand, + }), +}; + +async function runKillCommand(this: DorCommandContext, flags: KillFlags): Promise { + const confirmation = parseConfirmation(flags); + if (!confirmation.ok) return new Error(confirmation.message); + + const clientResult = resolveControlClient(this.options); + if (!clientResult.ok) return new Error(clientResult.message); + + try { + const response = await clientResult.value.killSurface({ + confirmation: confirmation.value, + surface: flags.surface, + }); + writeStdout(this, renderKillResponse(response)); + return undefined; + } catch (error) { + return new Error(error instanceof Error ? error.message : String(error)); + } +} + +function parseConfirmation(flags: KillFlags): ParseResult { + const confirmations = [ + flags.confirmAwaitUser === true, + flags.confirmDangerously === true, + flags.confirmIfRead !== undefined, + ].filter(Boolean).length; + + if (confirmations !== 1) { + return { ok: false, message: 'dor kill requires exactly one confirmation mode' }; + } + + if (flags.confirmAwaitUser) return { ok: true, value: { mode: 'await-user' } }; + if (flags.confirmDangerously) return { ok: true, value: { mode: 'dangerously' } }; + + const text = flags.confirmIfRead?.trim() ?? ''; + if (text.replace(/\s/g, '').length < 4) { + return { ok: false, message: 'dor kill --confirm-if-read requires at least 4 non-whitespace characters' }; + } + return { ok: true, value: { mode: 'if-read', text } }; +} + +function renderKillResponse(response: KillSurfaceResponse): string { + return `${response.status} ${response.surfaceRef}\n`; +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index bec9040..e6600d9 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -90,12 +90,29 @@ export interface ReadSurfaceResponse { text: string; } +export type KillSurfaceConfirmation = + | { mode: 'await-user' } + | { mode: 'if-read'; text: string } + | { mode: 'dangerously' }; + +export interface KillSurfaceRequest { + confirmation: KillSurfaceConfirmation; + surface: string; +} + +export interface KillSurfaceResponse { + status: 'killed'; + surfaceId?: string; + surfaceRef: string; +} + export interface ControlClient { listSurfaces(request: ListSurfacesRequest): Promise; splitSurface(request: SplitSurfaceRequest): Promise; ensureSurface(request: EnsureSurfaceRequest): Promise; sendSurface(request: SendSurfaceRequest): Promise; readSurface(request: ReadSurfaceRequest): Promise; + killSurface(request: KillSurfaceRequest): Promise; } export interface CliEnv { diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index b7ed454..24e77b1 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -3,6 +3,8 @@ import type { ControlClient, EnsureSurfaceRequest, EnsureSurfaceResponse, + KillSurfaceRequest, + KillSurfaceResponse, ListSurfacesRequest, ListSurfacesResponse, ReadSurfaceRequest, @@ -60,6 +62,10 @@ export class SocketControlClient implements ControlClient { return this.request('surface.read', request); } + killSurface(request: KillSurfaceRequest): Promise { + return this.request('surface.kill', request); + } + private request(method: string, params: unknown): Promise { const requestId = `dor-${this.idBase}-${++this.nextRequestId}`; return new Promise((resolve, reject) => { diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 22a26d1..6457ab7 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -106,6 +106,16 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { text: limited, }; }, + async killSurface(request) { + this.requests.push({ method: 'killSurface', request }); + return { + status: 'killed', + surfaceId: request.surface === 'surface:2' + ? '22222222-2222-4222-8222-222222222222' + : '11111111-1111-4111-8111-111111111111', + surfaceRef: request.surface, + }; + }, }; } @@ -309,6 +319,36 @@ test('read invalid lines output', async () => { await snapshot('read-invalid-lines', await runCli(['read', '--lines', '0'], { client: fixtureClient() })); }); +test('kill text output', async () => { + await snapshot( + 'kill-text', + await runCli(['kill', '--surface', 'surface:2', '--confirm-dangerously'], { client: fixtureClient() }), + ); +}); + +test('kill sends confirmation to the host', async () => { + const client = fixtureClient(); + await runCli(['kill', '--surface', 'title:repo "watch"', '--confirm-if-read', 'done'], { client }); + assert.deepEqual(client.requests, [{ + method: 'killSurface', + request: { + confirmation: { mode: 'if-read', text: 'done' }, + surface: 'title:repo "watch"', + }, + }]); +}); + +test('kill missing confirmation output', async () => { + await snapshot('kill-missing-confirmation', await runCli(['kill', '--surface', 'surface:2'], { client: fixtureClient() })); +}); + +test('kill short confirm-if-read output', async () => { + await snapshot( + 'kill-short-confirm-if-read', + await runCli(['kill', '--surface', 'surface:2', '--confirm-if-read', 'abc'], { client: fixtureClient() }), + ); +}); + test('list-panes text output', async () => { await snapshot('list-panes-text', await runCli(['list-panes'], { client: fixtureClient() })); }); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 00ab781..276ea4a 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -9,6 +9,7 @@ USAGE dor version dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index] + dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously] dor list-panes [--id-format refs|uuids|both] [--json] dor list-pane-surfaces [--id-format refs|uuids|both] [--json] [--pane id|ref|index] dor --help @@ -25,6 +26,7 @@ COMMANDS version Print the dor CLI version. send Send text or key input to a terminal surface. read Read terminal text from a surface. + kill Kill a terminal surface. list-panes List visible panes. list-pane-surfaces List surfaces in a pane. diff --git a/dor/test/snapshots/help/kill.md b/dor/test/snapshots/help/kill.md new file mode 100644 index 0000000..161437d --- /dev/null +++ b/dor/test/snapshots/help/kill.md @@ -0,0 +1,29 @@ +# dor kill + +Invocation: `dor kill --help` + +```text +USAGE + dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously] + dor kill --help + +Kills a terminal surface. One confirmation mode is required. + +--confirm-await-user asks Dormouse to prompt before killing. + +--confirm-if-read kills only if dor read --surface would return visible text containing the provided text. The text must contain at least 4 non-whitespace characters. + +--confirm-dangerously kills without further confirmation. Use only when automation has already validated the target. + +Text output: + killed surface:3 + +FLAGS + [--confirm-await-user] Ask Dormouse to prompt before killing. + [--confirm-dangerously] Kill without further confirmation. + [--confirm-if-read] Kill only if dor read contains this text. + --surface Surface to kill. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/kill-missing-confirmation.snap b/dor/test/snapshots/kill-missing-confirmation.snap new file mode 100644 index 0000000..15c5443 --- /dev/null +++ b/dor/test/snapshots/kill-missing-confirmation.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor kill requires exactly one confirmation mode diff --git a/dor/test/snapshots/kill-short-confirm-if-read.snap b/dor/test/snapshots/kill-short-confirm-if-read.snap new file mode 100644 index 0000000..697d717 --- /dev/null +++ b/dor/test/snapshots/kill-short-confirm-if-read.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor kill --confirm-if-read requires at least 4 non-whitespace characters diff --git a/dor/test/snapshots/kill-text.snap b/dor/test/snapshots/kill-text.snap new file mode 100644 index 0000000..79a2ca7 --- /dev/null +++ b/dor/test/snapshots/kill-text.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +killed surface:2 + +stderr: diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 4abdee3..c72936e 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -88,6 +88,7 @@ type ShellSpawnNoticeState = { type DorControlParams = { command?: unknown; + confirmation?: unknown; direction?: unknown; input?: unknown; inputCount?: unknown; @@ -209,6 +210,17 @@ function readVisibleSurfaceText(surfaceId: string, lines: number | undefined): s return limitLines(visibleLines.join('\n').replace(/\n+$/, ''), lines); } +function killConfirmationParam(value: unknown): { mode: 'await-user' } | { 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 }; + if (confirmation.mode === 'await-user') return { mode: 'await-user' }; + if (confirmation.mode === 'dangerously') return { mode: 'dangerously' }; + if (confirmation.mode === 'if-read' && typeof confirmation.text === 'string') { + return { mode: 'if-read', text: confirmation.text }; + } + return null; +} + function parseDorSplitDirection(value: unknown): DorSplitDirection | null { if (value === undefined || value === null) return 'auto'; if (value === 'left' || value === 'right' || value === 'up' || value === 'down' || value === 'auto') return value; @@ -1099,12 +1111,54 @@ export function Wall({ return; } + if (detail.method === 'surface.kill') { + const confirmation = killConfirmationParam(params.confirmation); + if (!confirmation) { + detail.respond({ ok: false, error: 'invalid kill confirmation' }); + return; + } + const surface = stringParam(params.surface); + if (!surface) { + detail.respond({ ok: false, error: 'surface is required' }); + return; + } + const target = resolveVisibleSurface(api, surface, detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return; + } + if (confirmation.mode === 'if-read') { + const text = readVisibleSurfaceText(target.value.id, undefined); + if (!text.includes(confirmation.text)) { + detail.respond({ ok: false, error: `surface '${target.value.ref}' read text did not contain confirmation text` }); + return; + } + } + if (confirmation.mode === 'await-user') { + const confirmed = window.confirm(`Kill ${target.value.ref}?`); + if (!confirmed) { + detail.respond({ ok: false, error: 'kill was not confirmed' }); + return; + } + } + killPaneImmediately(target.value.id); + detail.respond({ + ok: true, + result: { + status: 'killed', + surfaceId: target.value.id, + surfaceRef: target.value.ref, + }, + }); + return; + } + detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); }; window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, resolveVisibleSurface, surfaceRefForId]); + }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, killPaneImmediately, resolveVisibleSurface, surfaceRefForId]); const addSplitPanel = useCallback(( id: string | null, From 508b804e9b7bb83a6160ef0095aaa0d8f21b9bc7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 29 May 2026 22:38:28 -0700 Subject: [PATCH 6/7] Make dor kill --confirm-await-user block on the in-app dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The await-user path used window.confirm(), which a Tauri webview doesn't support synchronously — it returned false instantly, so the command ended immediately as "kill was not confirmed" and the dialog never opened. Open the existing in-app kill dialog instead and block the control request until the user accepts (matching letter) or rejects (Esc/any other key/ dismiss), wiring acceptKill/rejectKill to resolve the pending promise plus a safety net that resolves false if the dialog is dismissed by another path. A human-paced confirmation also outlasts both deadlines guarding the request, so disable them for await-user kills: the dor client passes no timeout, and the control server skips its safety-net timer (with a socket-close handler to release the now-unbounded pending entry if the client disconnects). Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/control-client.ts | 19 +++++++--- lib/src/components/Wall.tsx | 48 +++++++++++++++++++++--- standalone/sidecar/dor-control-server.js | 23 +++++++++++- 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index 24e77b1..a4f0d25 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -63,11 +63,16 @@ export class SocketControlClient implements ControlClient { } killSurface(request: KillSurfaceRequest): Promise { - return this.request('surface.kill', request); + // await-user blocks on a human answering the in-app dialog, so the client + // must not impose its own deadline — pass null to disable the timeout. + const timeoutMs = request.confirmation.mode === 'await-user' ? null : undefined; + return this.request('surface.kill', request, timeoutMs); } - private request(method: string, params: unknown): Promise { + private request(method: string, params: unknown, timeoutMs?: number | null): Promise { const requestId = `dor-${this.idBase}-${++this.nextRequestId}`; + // undefined → fall back to the per-client default; null → no deadline. + const effectiveTimeout = timeoutMs === undefined ? this.timeoutMs : timeoutMs; return new Promise((resolve, reject) => { const socket = createConnection({ path: this.socketPath }); let responseBuffer = ''; @@ -76,14 +81,16 @@ export class SocketControlClient implements ControlClient { const settle = (callback: () => void) => { if (settled) return; settled = true; - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); socket.destroy(); callback(); }; - const timeout = setTimeout(() => { - settle(() => reject(new Error(`timed out waiting for ${method}`))); - }, this.timeoutMs); + const timeout = effectiveTimeout === null + ? undefined + : setTimeout(() => { + settle(() => reject(new Error(`timed out waiting for ${method}`))); + }, effectiveTimeout); socket.setEncoding('utf8'); socket.on('connect', () => { diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index c72936e..ea9d94b 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -409,9 +409,24 @@ export function Wall({ const shakeTimerRef = useRef | null>(null); const confirmTimerRef = useRef | null>(null); + // Resolver for a `dor kill --confirm-await-user` request that is currently + // blocking on the in-app kill dialog. Accept resolves true, reject/dismiss + // resolves false; cleared once resolved so it only ever fires once. + const pendingKillResolveRef = useRef<((accepted: boolean) => void) | null>(null); + const resolvePendingKill = useCallback((accepted: boolean) => { + const resolve = pendingKillResolveRef.current; + if (!resolve) return; + pendingKillResolveRef.current = null; + resolve(accepted); + }, []); + useEffect(() => { if (!confirmKill && shakeTimerRef.current) clearTimeout(shakeTimerRef.current); - }, [confirmKill]); + // Safety net: if the dialog is dismissed by a path that bypasses + // accept/reject (e.g. clicking another pane), treat it as a rejection so a + // blocked await-user CLI call never hangs. + if (!confirmKill) resolvePendingKill(false); + }, [confirmKill, resolvePendingKill]); useEffect(() => () => { if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); @@ -431,9 +446,10 @@ export function Wall({ const rejectKill = useCallback(() => { const ck = confirmKillRef.current; if (!ck || ck.exit) return; + resolvePendingKill(false); setConfirmKill({ ...ck, exit: 'shake' }); shakeTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_SHAKE_MS); - }, []); + }, [resolvePendingKill]); useEffect(() => { onEventRef.current?.({ type: 'modeChange', mode }); }, [mode]); useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); @@ -477,10 +493,11 @@ export function Wall({ const acceptKill = useCallback(() => { const ck = confirmKillRef.current; if (!ck || ck.exit) return; + resolvePendingKill(true); setConfirmKill({ ...ck, exit: 'confirm' }); killPaneImmediately(ck.id); confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); - }, [killPaneImmediately]); + }, [killPaneImmediately, resolvePendingKill]); /** Select a door in the baseboard */ const selectDoor = useCallback((id: string) => { @@ -1135,11 +1152,30 @@ export function Wall({ } } if (confirmation.mode === 'await-user') { - const confirmed = window.confirm(`Kill ${target.value.ref}?`); - if (!confirmed) { + if (confirmKillRef.current || pendingKillResolveRef.current) { + detail.respond({ ok: false, error: 'a kill confirmation is already in progress' }); + return; + } + // Open the in-app kill dialog and block until the user accepts + // (matching letter) or rejects (Esc/any other key/dismiss). + const accepted = await new Promise((resolve) => { + pendingKillResolveRef.current = resolve; + setConfirmKill({ id: target.value.id, char: randomKillChar() }); + }); + if (!accepted) { detail.respond({ ok: false, error: 'kill was not confirmed' }); return; } + // acceptKill already ran killPaneImmediately and the confirm animation. + detail.respond({ + ok: true, + result: { + status: 'killed', + surfaceId: target.value.id, + surfaceRef: target.value.ref, + }, + }); + return; } killPaneImmediately(target.value.id); detail.respond({ @@ -1158,7 +1194,7 @@ export function Wall({ window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, killPaneImmediately, resolveVisibleSurface, surfaceRefForId]); + }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, killPaneImmediately, resolveVisibleSurface, setConfirmKill, surfaceRefForId]); const addSplitPanel = useCallback(( id: string | null, diff --git a/standalone/sidecar/dor-control-server.js b/standalone/sidecar/dor-control-server.js index 1206c94..d1d16fe 100644 --- a/standalone/sidecar/dor-control-server.js +++ b/standalone/sidecar/dor-control-server.js @@ -23,6 +23,17 @@ function createDorControlServer({ socketPath, token, send, timeoutMs = 10000 }) // down the long-lived sidecar/pty-host. socket.on('error', () => {}); + // A no-timeout request (await-user kill) would otherwise leak its pending + // entry forever if the client disconnects (Ctrl-C) before the webview + // answers. Drop any entries owned by this socket when it closes. + socket.on('close', () => { + for (const [requestId, entry] of pending) { + if (entry.socket !== socket) continue; + if (entry.timeout) clearTimeout(entry.timeout); + pending.delete(requestId); + } + }); + socket.on('data', (chunk) => { buffer += chunk; const newlineIndex = buffer.indexOf('\n'); @@ -52,7 +63,17 @@ function createDorControlServer({ socketPath, token, send, timeoutMs = 10000 }) return; } - const timeout = setTimeout(() => { + // await-user kills block on a human answering the in-app dialog, which can + // take arbitrarily long; the client disables its own deadline for these, so + // the safety-net timeout would only cut the user off prematurely. The + // socket 'close' handler releases the entry if the client goes away. + const awaitsUser = request.method === 'surface.kill' + && request.params + && typeof request.params === 'object' + && request.params.confirmation + && request.params.confirmation.mode === 'await-user'; + + const timeout = awaitsUser ? null : setTimeout(() => { pending.delete(request.requestId); writeResponse(socket, { requestId: request.requestId, ok: false, error: `timed out waiting for ${request.method}` }); }, timeoutMs); From d257ced99831a5cf4aea5f5ad2b68ae9c4fdb3e9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 29 May 2026 23:34:35 -0700 Subject: [PATCH 7/7] Remove dor kill --confirm-await-user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The await-user mode blocked the CLI call on a human answering an in-app modal, but the interaction model doesn't hold up: a CLI invocation hanging indefinitely on someone noticing a modal somewhere on screen — with no mechanism to route attention to it — is the wrong primitive. The kill-confirm dialog was also gated behind the Wall's command mode, so the confirm letter never reached it from a terminal in passthrough mode anyway. The remaining modes cover the real programmatic needs: --confirm-dangerously (unconditional) and --confirm-if-read (guard on visible text). A proper human-in-the-loop kill would need a non-blocking, attributed notification channel; build that if a consumer actually needs it. Reverts the await-user wiring across the CLI flag/parser, request types, control client timeout handling, and the webview handler. Keeps the control server's socket-close cleanup, which is a standalone correctness improvement. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/commands/kill.ts | 14 ++---- dor/src/commands/types.ts | 1 - dor/src/control-client.ts | 19 +++------ dor/test/snapshots/help/dor.md | 2 +- dor/test/snapshots/help/kill.md | 5 +-- lib/src/components/Wall.tsx | 54 +++--------------------- standalone/sidecar/dor-control-server.js | 18 ++------ 7 files changed, 21 insertions(+), 92 deletions(-) diff --git a/dor/src/commands/kill.ts b/dor/src/commands/kill.ts index 57a617f..f6b0af3 100644 --- a/dor/src/commands/kill.ts +++ b/dor/src/commands/kill.ts @@ -15,7 +15,6 @@ import { } from './shared.js'; interface KillFlags { - readonly confirmAwaitUser?: boolean; readonly confirmDangerously?: boolean; readonly confirmIfRead?: string; readonly surface: string; @@ -27,15 +26,15 @@ export const killCommand: Command = { { scope: 'root', findReplace: [ - ' dor kill [--confirm-await-user] [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', - ' dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously]', + ' dor kill [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', + ' dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously]', ], }, { scope: 'command-usage', findReplace: [ - ' dor kill [--confirm-await-user] [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', - ' dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously]', + ' dor kill [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', + ' dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously]', ], }, ], @@ -44,8 +43,6 @@ export const killCommand: Command = { brief: 'Kill a terminal surface.', fullDescription: `Kills a terminal surface. One confirmation mode is required. ---confirm-await-user asks Dormouse to prompt before killing. - --confirm-if-read kills only if dor read --surface would return visible text containing the provided text. The text must contain at least 4 non-whitespace characters. --confirm-dangerously kills without further confirmation. Use only when automation has already validated the target. @@ -55,7 +52,6 @@ Text output: }, parameters: { flags: { - confirmAwaitUser: { kind: 'boolean', brief: 'Ask Dormouse to prompt before killing.', optional: true, withNegated: false }, confirmDangerously: { kind: 'boolean', brief: 'Kill without further confirmation.', optional: true, withNegated: false }, confirmIfRead: { kind: 'parsed', parse: stringParser, brief: 'Kill only if dor read contains this text.', optional: true, placeholder: 'text' }, surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to kill.', placeholder: 'id|ref|index' }, @@ -86,7 +82,6 @@ async function runKillCommand(this: DorCommandContext, flags: KillFlags): Promis function parseConfirmation(flags: KillFlags): ParseResult { const confirmations = [ - flags.confirmAwaitUser === true, flags.confirmDangerously === true, flags.confirmIfRead !== undefined, ].filter(Boolean).length; @@ -95,7 +90,6 @@ function parseConfirmation(flags: KillFlags): ParseResult { - // await-user blocks on a human answering the in-app dialog, so the client - // must not impose its own deadline — pass null to disable the timeout. - const timeoutMs = request.confirmation.mode === 'await-user' ? null : undefined; - return this.request('surface.kill', request, timeoutMs); + return this.request('surface.kill', request); } - private request(method: string, params: unknown, timeoutMs?: number | null): Promise { + private request(method: string, params: unknown): Promise { const requestId = `dor-${this.idBase}-${++this.nextRequestId}`; - // undefined → fall back to the per-client default; null → no deadline. - const effectiveTimeout = timeoutMs === undefined ? this.timeoutMs : timeoutMs; return new Promise((resolve, reject) => { const socket = createConnection({ path: this.socketPath }); let responseBuffer = ''; @@ -81,16 +76,14 @@ export class SocketControlClient implements ControlClient { const settle = (callback: () => void) => { if (settled) return; settled = true; - if (timeout) clearTimeout(timeout); + clearTimeout(timeout); socket.destroy(); callback(); }; - const timeout = effectiveTimeout === null - ? undefined - : setTimeout(() => { - settle(() => reject(new Error(`timed out waiting for ${method}`))); - }, effectiveTimeout); + const timeout = setTimeout(() => { + settle(() => reject(new Error(`timed out waiting for ${method}`))); + }, this.timeoutMs); socket.setEncoding('utf8'); socket.on('connect', () => { diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 276ea4a..2600808 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -9,7 +9,7 @@ USAGE dor version dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index] - dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously] + dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously] dor list-panes [--id-format refs|uuids|both] [--json] dor list-pane-surfaces [--id-format refs|uuids|both] [--json] [--pane id|ref|index] dor --help diff --git a/dor/test/snapshots/help/kill.md b/dor/test/snapshots/help/kill.md index 161437d..98603b8 100644 --- a/dor/test/snapshots/help/kill.md +++ b/dor/test/snapshots/help/kill.md @@ -4,13 +4,11 @@ Invocation: `dor kill --help` ```text USAGE - dor kill --surface id|ref|index [--confirm-await-user|--confirm-if-read text|--confirm-dangerously] + dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously] dor kill --help Kills a terminal surface. One confirmation mode is required. ---confirm-await-user asks Dormouse to prompt before killing. - --confirm-if-read kills only if dor read --surface would return visible text containing the provided text. The text must contain at least 4 non-whitespace characters. --confirm-dangerously kills without further confirmation. Use only when automation has already validated the target. @@ -19,7 +17,6 @@ Text output: killed surface:3 FLAGS - [--confirm-await-user] Ask Dormouse to prompt before killing. [--confirm-dangerously] Kill without further confirmation. [--confirm-if-read] Kill only if dor read contains this text. --surface Surface to kill. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index ea9d94b..e655662 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -210,10 +210,9 @@ function readVisibleSurfaceText(surfaceId: string, lines: number | undefined): s return limitLines(visibleLines.join('\n').replace(/\n+$/, ''), lines); } -function killConfirmationParam(value: unknown): { mode: 'await-user' } | { mode: 'if-read'; text: string } | { mode: 'dangerously' } | null { +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 }; - if (confirmation.mode === 'await-user') return { mode: 'await-user' }; if (confirmation.mode === 'dangerously') return { mode: 'dangerously' }; if (confirmation.mode === 'if-read' && typeof confirmation.text === 'string') { return { mode: 'if-read', text: confirmation.text }; @@ -409,24 +408,9 @@ export function Wall({ const shakeTimerRef = useRef | null>(null); const confirmTimerRef = useRef | null>(null); - // Resolver for a `dor kill --confirm-await-user` request that is currently - // blocking on the in-app kill dialog. Accept resolves true, reject/dismiss - // resolves false; cleared once resolved so it only ever fires once. - const pendingKillResolveRef = useRef<((accepted: boolean) => void) | null>(null); - const resolvePendingKill = useCallback((accepted: boolean) => { - const resolve = pendingKillResolveRef.current; - if (!resolve) return; - pendingKillResolveRef.current = null; - resolve(accepted); - }, []); - useEffect(() => { if (!confirmKill && shakeTimerRef.current) clearTimeout(shakeTimerRef.current); - // Safety net: if the dialog is dismissed by a path that bypasses - // accept/reject (e.g. clicking another pane), treat it as a rejection so a - // blocked await-user CLI call never hangs. - if (!confirmKill) resolvePendingKill(false); - }, [confirmKill, resolvePendingKill]); + }, [confirmKill]); useEffect(() => () => { if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); @@ -446,10 +430,9 @@ export function Wall({ const rejectKill = useCallback(() => { const ck = confirmKillRef.current; if (!ck || ck.exit) return; - resolvePendingKill(false); setConfirmKill({ ...ck, exit: 'shake' }); shakeTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_SHAKE_MS); - }, [resolvePendingKill]); + }, []); useEffect(() => { onEventRef.current?.({ type: 'modeChange', mode }); }, [mode]); useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); @@ -493,11 +476,10 @@ export function Wall({ const acceptKill = useCallback(() => { const ck = confirmKillRef.current; if (!ck || ck.exit) return; - resolvePendingKill(true); setConfirmKill({ ...ck, exit: 'confirm' }); killPaneImmediately(ck.id); confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); - }, [killPaneImmediately, resolvePendingKill]); + }, [killPaneImmediately]); /** Select a door in the baseboard */ const selectDoor = useCallback((id: string) => { @@ -1151,32 +1133,6 @@ export function Wall({ return; } } - if (confirmation.mode === 'await-user') { - if (confirmKillRef.current || pendingKillResolveRef.current) { - detail.respond({ ok: false, error: 'a kill confirmation is already in progress' }); - return; - } - // Open the in-app kill dialog and block until the user accepts - // (matching letter) or rejects (Esc/any other key/dismiss). - const accepted = await new Promise((resolve) => { - pendingKillResolveRef.current = resolve; - setConfirmKill({ id: target.value.id, char: randomKillChar() }); - }); - if (!accepted) { - detail.respond({ ok: false, error: 'kill was not confirmed' }); - return; - } - // acceptKill already ran killPaneImmediately and the confirm animation. - detail.respond({ - ok: true, - result: { - status: 'killed', - surfaceId: target.value.id, - surfaceRef: target.value.ref, - }, - }); - return; - } killPaneImmediately(target.value.id); detail.respond({ ok: true, @@ -1194,7 +1150,7 @@ export function Wall({ window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, killPaneImmediately, resolveVisibleSurface, setConfirmKill, surfaceRefForId]); + }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, killPaneImmediately, resolveVisibleSurface, surfaceRefForId]); const addSplitPanel = useCallback(( id: string | null, diff --git a/standalone/sidecar/dor-control-server.js b/standalone/sidecar/dor-control-server.js index d1d16fe..bc9f4b6 100644 --- a/standalone/sidecar/dor-control-server.js +++ b/standalone/sidecar/dor-control-server.js @@ -23,9 +23,9 @@ function createDorControlServer({ socketPath, token, send, timeoutMs = 10000 }) // down the long-lived sidecar/pty-host. socket.on('error', () => {}); - // A no-timeout request (await-user kill) would otherwise leak its pending - // entry forever if the client disconnects (Ctrl-C) before the webview - // answers. Drop any entries owned by this socket when it closes. + // If the client disconnects (timeout/Ctrl-C) before the webview answers, + // release any entries owned by this socket right away rather than letting + // them linger until their own timeout fires against a dead socket. socket.on('close', () => { for (const [requestId, entry] of pending) { if (entry.socket !== socket) continue; @@ -63,17 +63,7 @@ function createDorControlServer({ socketPath, token, send, timeoutMs = 10000 }) return; } - // await-user kills block on a human answering the in-app dialog, which can - // take arbitrarily long; the client disables its own deadline for these, so - // the safety-net timeout would only cut the user off prematurely. The - // socket 'close' handler releases the entry if the client goes away. - const awaitsUser = request.method === 'surface.kill' - && request.params - && typeof request.params === 'object' - && request.params.confirmation - && request.params.confirmation.mode === 'await-user'; - - const timeout = awaitsUser ? null : setTimeout(() => { + const timeout = setTimeout(() => { pending.delete(request.requestId); writeResponse(socket, { requestId: request.requestId, ok: false, error: `timed out waiting for ${request.method}` }); }, timeoutMs);