import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; import { Box, Text } from "@earendil-works/pi-tui"; import { type Static, Type } from "typebox"; import { buildDriver, type DriverAction, PI_TEXTUAL_LIB } from "./driver.ts"; import { SessionManager, type SessionUpdate } from "./session.ts"; const PY_EXTENSION = /\.py$/; const DEFAULT_SIZE: [number, number] = [80, 25]; const DEFAULT_TIMEOUT = 120_000; const ClickTarget = Type.Object( { button: Type.Optional( Type.Integer({ description: "Mouse button: 1=left, 2=middle, 3=right. Default 1.", maximum: 5, minimum: 1, }) ), control: Type.Optional( Type.Boolean({ description: "Hold control while clicking. Default false.", }) ), meta: Type.Optional( Type.Boolean({ description: "Hold meta while clicking. Default false." }) ), offset: Type.Optional( Type.Tuple([Type.Integer(), Type.Integer()], { description: "Offset relative to the widget origin, in cells. Default [0, 0].", }) ), selector: Type.String({ description: "Widget selector such as '#button', 'Button', or '.primary'. Omit to click at screen coordinates.", }), shift: Type.Optional( Type.Boolean({ description: "Hold shift while clicking. Default false." }) ), times: Type.Optional( Type.Integer({ description: "Number of clicks. Default 1.", maximum: 10, minimum: 1, }) ), }, { additionalProperties: false } ); const Action = Type.Union( [ Type.Object( { press: Type.Union( [ Type.String({ description: "A single key such as 'tab', 'ctrl+c', 'enter', or 'a'.", }), Type.Array(Type.String()), ], { description: "Key or ordered list of keys to press." } ), }, { additionalProperties: false } ), Type.Object( { click: Type.Union([Type.String(), ClickTarget], { description: "Widget selector to click, or an object with selector/offset/times/button.", }), }, { additionalProperties: false } ), Type.Object( { double_click: Type.Union([Type.String(), ClickTarget], { description: "Widget selector to double-click, or click options.", }), }, { additionalProperties: false } ), Type.Object( { hover: Type.Union([Type.String(), ClickTarget], { description: "Widget selector to hover, or hover options.", }), }, { additionalProperties: false } ), Type.Object( { type: Type.String({ description: "Type text into the focused widget, one key press per character. Newlines become enter.", }), }, { additionalProperties: false } ), Type.Object( { scroll: Type.Object( { amount: Type.Optional( Type.Integer({ description: "Number of cells to scroll for down/up/left/right. Default is one page.", minimum: 1, }) ), direction: Type.Union( [ Type.Literal("down"), Type.Literal("up"), Type.Literal("left"), Type.Literal("right"), Type.Literal("home"), Type.Literal("end"), ], { description: "Scroll direction; home/end jump to the start/end of the widget.", } ), selector: Type.String({ description: "Widget selector to scroll, such as '#list' or 'OptionList'.", }), }, { additionalProperties: false } ), }, { additionalProperties: false } ), Type.Object( { pause: Type.Number({ description: "Wait this many seconds before the next action.", minimum: 0, }), }, { additionalProperties: false } ), Type.Object( { resize: Type.Tuple([ Type.Integer({ minimum: 1 }), Type.Integer({ minimum: 1 }), ]), }, { additionalProperties: false } ), Type.Object( { screenshot: Type.Boolean({ description: "Export an SVG screenshot at this point of the run. Requires 'screenshot': true on the call.", }), }, { additionalProperties: false } ), ], { description: "One pilot action: press keys, click a selector, hover, type text, scroll a widget, pause, resize, or export a screenshot.", } ); const Params = Type.Object({ actions: Type.Optional( Type.Array(Action, { description: "Actions executed in order after the app starts. Press simulates keys such as 'tab' or 'ctrl+c'. Click/double_click/hover target a widget selector such as '#button' or an object with selector, offset, times, and button. Pause waits in seconds. Resize changes the terminal size. Screenshot exports an SVG at that point.", }) ), app: Type.Optional( Type.String({ description: "Path to the Python file that defines the Textual app. The file must define a module-level 'app' instance, or exactly one App subclass. Required for one-shot runs and when opening a new session; omit it when acting on an open session.", }) ), at: Type.Optional( Type.Tuple([Type.Integer({ minimum: 0 }), Type.Integer({ minimum: 0 })], { description: "Screen coordinates [x, y] of the widget to report in the result as widgetAt, e.g. [40, 12].", }) ), close: Type.Optional( Type.Boolean({ description: "When true with a 'session', closes that session and stops its app. The session subprocess is terminated.", }) ), messages: Type.Optional( Type.Boolean({ description: "When true, the result includes a summary of the messages the app processed: counts by message type plus the most recent messages.", }) ), python: Type.Optional( Type.String({ description: "Python executable used to run the app. Defaults to 'python3'. Textual must be installed for this interpreter.", }) ), query: Type.Optional( Type.Array( Type.String({ description: "Widget selector such as '#label' or 'Input'. Multiple selectors are allowed.", }), { description: "Inspect the state of widgets matching these selectors after the actions. The result includes a per-widget snapshot: type, id, classes, visibility, focus, and widget-specific state such as Input value, Button label, or DataTable row count.", } ) ), screenshot: Type.Optional( Type.Boolean({ description: "When true, exports the final screen as an SVG file next to the app file. The path and size are returned in the result details.", }) ), session: Type.Optional( Type.String({ description: "Name of a persistent session. With 'app', opens a new session that keeps the app alive in a subprocess between calls. Without 'app', drives the already-open session. Sessions must be closed with 'close'. One-shot runs without 'session' start and stop the app in a single call.", }) ), size: Type.Optional( Type.Tuple([Type.Integer({ minimum: 1 }), Type.Integer({ minimum: 1 })], { description: "Terminal size as [width, height]. Defaults to [80, 25].", }) ), timeout: Type.Optional( Type.Integer({ description: "Execution timeout in milliseconds. Defaults to 120000.", maximum: 3_600_000, minimum: 1, }) ), tree: Type.Optional( Type.Boolean({ description: "When true, the result includes the widget tree: every widget with its type, id, classes, visibility, disabled and focus state.", }) ), }); interface RunDetails { /** The app's own exit value when the app itself called exit() before the * run finished. Present only in that case; the value may be null. */ appExitValue?: unknown; /** Absolute path of the app file that ran. */ appPath: string; /** True when the call closed a session. */ closed?: boolean; /** The full parsed driver result document. */ exitValue: unknown; /** The focused widget and its ancestors, when 'tree' was requested. */ focused?: unknown; /** Message capture summary, when 'messages' was requested. */ messages?: unknown; /** Python interpreter that ran the app. */ python: string; /** Size of the exported SVG file in bytes, when requested. */ screenshotBytes?: number; /** Path of the exported SVG screenshot, when requested. */ screenshotPath?: string; /** Name of the persistent session, when the call used one. */ session?: string; /** Terminal size at capture time as [width, height]. */ size?: [number, number]; /** Screen text after the actions ran. */ text: string; /** Widget tree, when 'tree' was requested. */ tree?: unknown; /** State of the widget under 'at' coordinates, when 'at' was requested. */ widgetAt?: unknown; /** Widget state snapshots, when 'query' was requested. */ widgets?: unknown; } type ToolParams = Static; type ToolOnUpdate = | ((update: { content: { type: "text"; text: string }[]; details: RunDetails; }) => void) | undefined; interface ToolResult { content: { type: "text"; text: string }[]; details: RunDetails; } /** A driver result envelope shared by one-shot runs and session commands. */ interface DriverResultLike { appExitValue?: unknown; error?: string; focused?: unknown; messages?: unknown; screenshotBytes?: number; screenshotPath?: string; size?: [number, number]; svgBytes?: number; svgPath?: string; text?: string; tree?: unknown; widgetAt?: unknown; widgets?: unknown; } function toolResult( appPath: string, python: string, session: string | undefined, result: DriverResultLike ): ToolResult { const text = truncateScreen(result.text ?? ""); const details: RunDetails = { appPath, ...("appExitValue" in result ? { appExitValue: result.appExitValue } : {}), exitValue: result, python, ...(session ? { session } : {}), ...(result.size ? { size: result.size } : {}), ...((result.svgPath ?? result.screenshotPath) ? { screenshotBytes: result.svgBytes ?? result.screenshotBytes, screenshotPath: result.svgPath ?? result.screenshotPath, } : {}), text, }; if (result.tree !== undefined) { details.tree = result.tree; } if (result.widgets !== undefined) { details.widgets = result.widgets; } if (result.focused !== undefined) { details.focused = result.focused; } if (result.widgetAt !== undefined) { details.widgetAt = result.widgetAt; } if (result.messages !== undefined) { details.messages = result.messages; } return { content: [ { text: buildContent( { focused: result.focused, messages: result.messages, tree: result.tree, widgetAt: result.widgetAt, widgets: result.widgets, }, text ), type: "text", }, ], details, }; } /** Truncates the screen text for the result (48k chars, 2000 lines). */ function truncateScreen(text: string): string { let truncated = text; if (truncated.length > 48_000) { truncated = `${truncated.slice(0, 48_000)}\n[truncated]`; } if (truncated.split("\n").length > 2000) { truncated = `${truncated.split("\n").slice(0, 2000).join("\n")}\n[truncated]`; } return truncated; } interface TreeNode { children?: TreeNode[]; classes?: string[]; disabled?: boolean; focus?: boolean; id?: string | null; name?: string | null; truncated?: boolean; type?: string; visible?: boolean; } /** Renders the widget tree as an indented text outline. */ function formatTree(root: TreeNode | undefined, maxLines = 200): string { if (!root) { return "(no widget tree)"; } const lines: string[] = []; const visit = (node: TreeNode, depth: number): void => { if (lines.length >= maxLines) { return; } const flags = [ node.focus ? "focused" : null, node.disabled ? "disabled" : null, node.visible === false ? "hidden" : null, ].filter((flag): flag is string => flag !== null); const idPart = node.id ? `#${node.id}` : ""; const classesPart = node.classes && node.classes.length > 0 ? `.${node.classes.join(".")}` : ""; const flagsPart = flags.length > 0 ? ` [${flags.join(", ")}]` : ""; lines.push( `${" ".repeat(depth)}${node.type ?? "?"}${idPart}${classesPart}${flagsPart}` ); for (const child of node.children ?? []) { visit(child, depth + 1); } }; visit(root, 0); if (root.truncated) { lines.push("[tree truncated]"); } if (lines.length >= maxLines) { lines.push(`[tree truncated at ${maxLines} lines]`); } return lines.join("\n"); } interface WidgetGroup { count?: number; error?: string; selector?: string; widgets?: Record[]; } /** Renders widget state snapshots as readable lines. */ const STATE_KEYS = new Set(["classes", "id", "name", "type"]); /** Renders one widget state snapshot as a single line. */ function formatWidgetLine(widget: Record): string { const idPart = widget.id ? `#${widget.id}` : ""; const parts = Object.entries(widget) .filter(([key]) => !STATE_KEYS.has(key)) .map(([key, value]) => `${key}=${JSON.stringify(value)}`); return ` ${widget.type}${idPart} ${parts.join(" ")}`.trimEnd(); } function formatWidgets(groups: unknown, maxLines = 120): string { if (!Array.isArray(groups)) { return "(no widget states)"; } const lines: string[] = []; for (const rawGroup of groups) { if (lines.length >= maxLines) { break; } const group = rawGroup as WidgetGroup; if (group.error) { lines.push(`${group.selector}: ${group.error}`); continue; } lines.push(`${group.selector}: ${group.count ?? 0} match(es)`); for (const widget of group.widgets ?? []) { if (lines.length >= maxLines) { break; } lines.push(formatWidgetLine(widget)); } } if (lines.length >= maxLines) { lines.push(`[widget states truncated at ${maxLines} lines]`); } return lines.join("\n"); } /** Renders the focused-widget chain, innermost first. */ function formatFocused(chain: unknown): string { if (!Array.isArray(chain) || chain.length === 0) { return ""; } const labels = chain.map((node) => { const widget = node as { id?: string | null; type?: string }; return widget.id ? `${widget.type}#${widget.id}` : `${widget.type ?? "?"}`; }); return `Focused: ${labels.join(" <- ")}`; } /** Renders the widget under the requested screen coordinates. */ function formatWidgetAt(state: unknown): string { if (!state || typeof state !== "object") { return ""; } const widget = state as Record; const region = widget.region as { x?: number; y?: number } | undefined; const at = region ? `[${region.x}, ${region.y}]` : ""; return `Widget at ${at}:\n${formatWidgetLine(widget).trimStart()}`; } /** Renders the message capture summary on one line. */ function formatMessages(messages: unknown): string { if (!messages || typeof messages !== "object") { return ""; } const summary = messages as { counts?: Record; total?: number; }; const counts = Object.entries(summary.counts ?? {}) .map(([name, count]) => `${name}=${count}`) .join(" "); return `Messages processed: ${summary.total ?? 0}${counts ? ` (${counts})` : ""}`; } /** Builds the LLM-visible content text from the screen text and extras. */ function buildContent( extras: { focused?: unknown; messages?: unknown; tree?: unknown; widgetAt?: unknown; widgets?: unknown; }, truncatedText: string ): string { let content = truncatedText.length === 0 ? "The app produced no screen text." : `Screen text after the actions:\n${truncatedText}`; if (extras.widgetAt !== undefined) { content += `\n\n${formatWidgetAt(extras.widgetAt)}`; } if (extras.focused !== undefined) { content += `\n\n${formatFocused(extras.focused)}`; } if (extras.tree !== undefined) { content += `\n\nWidget tree:\n${formatTree(extras.tree as TreeNode)}`; } if (extras.widgets !== undefined) { content += `\n\nWidget states:\n${formatWidgets(extras.widgets)}`; } if (extras.messages !== undefined) { content += `\n\n${formatMessages(extras.messages)}`; } if (content.length > 48_000) { content = `${content.slice(0, 48_000)}\n[truncated]`; } return content; } function wantsScreenshot( screenshot: boolean | undefined, actions: DriverAction[] | undefined ): boolean { return ( screenshot === true || (actions ?? []).some((action) => "screenshot" in action) ); } /** Picks a screenshot path next to the app file that does not exist yet. */ function uniqueScreenshotPath(appPath: string): string { const stem = basename(appPath).replace(PY_EXTENSION, ""); const stamp = Date.now(); let path = join(dirname(appPath), `${stem}-${stamp}.svg`); for (let n = 2; existsSync(path); n += 1) { path = join(dirname(appPath), `${stem}-${stamp}-${n}.svg`); } return path; } function updateContent( onUpdate: | ((update: { content: { type: "text"; text: string }[]; details: RunDetails; }) => void) | undefined, update: SessionUpdate, total: number, appPath: string, python: string ): void { const text = truncateScreen(update.text); onUpdate?.({ content: [ { text: `textual_run step ${update.index + 1}/${total}:\n${text}`, type: "text", }, ], details: { appPath, exitValue: {}, python, text }, }); } /** Formats the action count for the call preview. */ function renderActionCount(actions: unknown): string { return Array.isArray(actions) && actions.length > 0 ? ` · ${actions.length} action${actions.length === 1 ? "" : "s"}` : ""; } /** Renders the boxed screen preview for a finished run. */ function renderScreenBox( details: RunDetails, expanded: boolean, theme: Theme ): Box { const size = details.size ? `${details.size[0]}x${details.size[1]}` : ""; const session = details.session ? ` · session ${details.session}` : ""; const closed = details.closed ? " · closed" : ""; const header = `${details.appPath}${size ? ` · ${size}` : ""}${session}${closed}`; const lines = details.text.split("\n"); const maxShown = expanded ? 500 : 24; const shown = lines.slice(0, maxShown); const box = new Box(1, 1); box.addChild(new Text(theme.fg("muted", header), 0, 0)); box.addChild( shown.length > 0 ? new Text(theme.fg("text", shown.join("\n")), 0, 0) : new Text(theme.fg("dim", "(no screen text)"), 0, 0) ); if (lines.length > shown.length) { box.addChild( new Text( theme.fg("dim", `… ${lines.length - shown.length} more lines`), 0, 0 ) ); } return box; } /** Closes a named session and reports the close result. */ async function closeSession( sessions: SessionManager, session: string, python: string, signal: AbortSignal | undefined, timeoutMs: number ): Promise { const appPath = sessions.appPathOf(session); const result = await sessions.close(session, { signal, timeoutMs: Math.min(timeoutMs, 10_000), }); return { content: [ { text: `Closed textual session '${session}'.`, type: "text", }, ], details: { appPath: appPath ?? "", closed: true, exitValue: result, python, session, text: "", }, }; } /** Runs an actions command on a session, opening it first when needed. */ async function runSession( sessions: SessionManager, params: ToolParams, session: string, python: string, size: [number, number], timeoutMs: number, cwd: string, signal: AbortSignal | undefined, onUpdate: ToolOnUpdate ): Promise { if (!sessions.isOpen(session)) { if (!params.app) { throw new Error( `textual_run: no open session '${session}'. Pass 'app' with 'session' to open it, or omit 'session' for a one-shot run. Open sessions: ${sessions.names().join(", ") || "(none)"}.` ); } await sessions.open({ appPath: resolve(cwd, params.app), cwd, name: session, python, signal, size, timeoutMs, }); } else if (params.app) { throw new Error( `textual_run: session '${session}' is already open. Drop 'app' to act on it.` ); } const appPath = sessions.appPathOf(session) ?? ""; const actions = params.actions ?? []; const takeScreenshot = wantsScreenshot(params.screenshot, actions); const result = await sessions.command( session, { actions, ...(params.query ? { query: params.query } : {}), ...(takeScreenshot ? { screenshotPath: uniqueScreenshotPath(appPath) } : {}), ...(params.tree ? { tree: true } : {}), ...(params.messages ? { messages: true } : {}), ...(params.at ? { at: params.at } : {}), command: "actions", }, { onUpdate: (update) => updateContent(onUpdate, update, actions.length, appPath, python), signal, timeoutMs, } ); return toolResult(appPath, python, session, result); } /** Reaps a spawned driver process; idempotent. */ const KILL_GRACE_MS = 1000; interface DriverProcessOptions { cwd: string; signal?: AbortSignal; spawnImpl: typeof spawn; timeoutMs: number; } /** State accumulated while a driver process runs. */ interface DriverRunState { exitCode: number | null; invalidJsonLine?: string; resultDoc?: DriverResultLike; stderrTail: string; stdoutBuffer: string; stdoutTail: string; } /** Parses one driver stdout line; streams updates and records the result. */ function processDriverLine( line: string, state: DriverRunState, emitUpdate: (update: SessionUpdate) => void ): void { if (!line.startsWith("{")) { return; } let doc: Record; try { doc = JSON.parse(line) as Record; } catch { state.invalidJsonLine ??= line; return; } if (doc.kind === "update") { emitUpdate({ index: typeof doc.index === "number" ? doc.index : 0, text: typeof doc.text === "string" ? doc.text : "", }); } else { state.resultDoc = doc as DriverResultLike; } } /** Builds the failure message for a driver that never produced a result. */ function driverFailureMessage(state: DriverRunState): string { if (state.invalidJsonLine) { return `textual_run: the driver returned invalid JSON: ${state.invalidJsonLine.slice(0, 500)}`; } return `textual_run failed (exit ${state.exitCode ?? "unknown"})\nstdout: ${state.stdoutTail || "(empty)"}\nstderr: ${state.stderrTail || "(empty)"}`; } /** * Spawns a driver, streams its "update" documents through emitUpdate as they * arrive, and resolves with the final result document when the process exits. * Timeouts and aborts kill the process (SIGTERM, then SIGKILL after a grace * period). */ function runDriverProcess( python: string, driverPath: string, options: DriverProcessOptions, emitUpdate: (update: SessionUpdate) => void ): Promise { return new Promise((fulfill, reject) => { const state: DriverRunState = { exitCode: null, stderrTail: "", stdoutBuffer: "", stdoutTail: "", }; let settled = false; const child = options.spawnImpl(python, [driverPath], { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], }); const kill = (): void => { if (child.exitCode === null) { try { child.kill("SIGTERM"); } catch { // Already gone. } const force = setTimeout(() => { try { child.kill("SIGKILL"); } catch { // Already gone. } }, KILL_GRACE_MS); force.unref(); } }; const settle = (error?: Error): void => { if (settled) { return; } settled = true; clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); if (error) { reject(error); } else if (state.resultDoc) { fulfill(state.resultDoc); } else { reject(new Error(driverFailureMessage(state))); } }; const onAbort = (): void => { kill(); settle( new Error( `textual_run: the app subprocess was killed after ${options.timeoutMs} ms (timeout or abort)` ) ); }; if (options.signal?.aborted) { onAbort(); } else { options.signal?.addEventListener("abort", onAbort, { once: true }); } const timer = setTimeout(onAbort, options.timeoutMs); timer.unref(); child.stdout?.on("data", (chunk: Buffer) => { const text = chunk.toString(); state.stdoutTail = (state.stdoutTail + text).slice(-4000); state.stdoutBuffer += text; let newline = state.stdoutBuffer.indexOf("\n"); while (newline >= 0) { const line = state.stdoutBuffer.slice(0, newline).trim(); state.stdoutBuffer = state.stdoutBuffer.slice(newline + 1); processDriverLine(line, state, emitUpdate); newline = state.stdoutBuffer.indexOf("\n"); } }); child.stderr?.on("data", (chunk: Buffer) => { state.stderrTail = (state.stderrTail + chunk.toString()).slice(-2000); }); child.on("error", (error) => settle(error)); child.on("exit", (code) => { state.exitCode = code; settle(); }); }); } /** Runs the app once in a subprocess and returns the final screen. */ async function runOneShot( params: ToolParams, appPath: string, python: string, size: [number, number], timeoutMs: number, cwd: string, signal: AbortSignal | undefined, onUpdate: ToolOnUpdate, spawnImpl: typeof spawn ): Promise { const actions = params.actions ?? []; const takeScreenshot = wantsScreenshot(params.screenshot, actions); const dir = await mkdtemp(join(tmpdir(), "pi-textual-")); try { const driverPath = join(dir, "driver.py"); await writeFile( driverPath, buildDriver({ actions, appPath, query: params.query, screenshot: takeScreenshot, screenshotPath: uniqueScreenshotPath(appPath), size, tree: params.tree, updates: actions.length > 0, ...(params.messages ? { messages: true } : {}), ...(params.at ? { at: params.at } : {}), }) ); await writeFile(join(dir, "pi_textual_lib.py"), PI_TEXTUAL_LIB); const result = await runDriverProcess( python, driverPath, { cwd, signal, spawnImpl, timeoutMs }, (update) => updateContent(onUpdate, update, actions.length, appPath, python) ); if (result.error) { throw new Error( `textual_run: the app failed: ${result.error.slice(0, 4000)}` ); } return toolResult(appPath, python, undefined, result); } finally { await rm(dir, { force: true, recursive: true }); } } /** * Runs a Textual app headless in a Python subprocess and drives it with a * pilot. One-shot runs execute a full action script and return the screen * text, the widget tree and widget states on request, and optionally an SVG * screenshot. Named sessions keep the app alive across calls so the agent can * interact step by step. */ export interface ExtensionOptions { /** Spawn implementation, overridable in tests. */ spawnImpl?: typeof spawn; } export default function (pi: ExtensionAPI, options?: ExtensionOptions): void { const spawnImpl = options?.spawnImpl ?? spawn; const sessions = new SessionManager(spawnImpl); pi.on("session_shutdown", () => { sessions.shutdownAll(); }); pi.registerTool({ description: "Runs a Textual Python app headless in a subprocess and drives it with pilot actions (press, click, double_click, hover, type, scroll, pause, resize, screenshot). Returns the screen text, an optional widget tree and widget states, an optional message summary, and an optional SVG screenshot. With a 'session' name the app stays alive across calls so you can interact step by step; close it with 'close'.", async execute(_id, params, signal, onUpdate, ctx): Promise { const python = params.python ?? "python3"; const size = params.size ?? DEFAULT_SIZE; const timeoutMs = params.timeout ?? DEFAULT_TIMEOUT; // Closing a session. if (params.close) { if (!params.session) { throw new Error( "textual_run: 'close' requires a 'session' name to close." ); } return await closeSession( sessions, params.session, python, signal, timeoutMs ); } // Acting on a persistent session. if (params.session) { return await runSession( sessions, params, params.session, python, size, timeoutMs, ctx.cwd, signal, onUpdate ); } // One-shot run. if (!params.app) { throw new Error( "textual_run: 'app' is required unless 'session' names an open session." ); } return await runOneShot( params, resolve(ctx.cwd, params.app), python, size, timeoutMs, ctx.cwd, signal, onUpdate, spawnImpl ); }, label: "Run a Textual app", name: "textual_run", parameters: Params, promptGuidelines: [ "Use textual_run with a 'session' name for multi-step interaction: open once with 'app', then send actions and read the screen between steps, and close the session with 'close' when done. Without 'session', textual_run starts and stops the app in a single call.", "Use textual_run's 'tree' and 'query' parameters to inspect widget structure and state (Input values, Button labels, DataTable row counts) instead of guessing from the screen text.", ], promptSnippet: "Run a Textual Python app headless, drive it with pilot actions, inspect its widgets, and keep it alive in a named session across calls.", renderCall(args, theme, _context) { const input = args as Record; let text = theme.fg("toolTitle", theme.bold("textual_run ")); const app = typeof input.app === "string" ? input.app : undefined; if (app) { text += theme.fg("accent", app); } const session = typeof input.session === "string" ? input.session : undefined; if (session) { text += theme.fg("muted", ` session:${session}`); } text += renderActionCount(input.actions); if (input.tree) { text += theme.fg("dim", " · tree"); } if (Array.isArray(input.query) && input.query.length > 0) { text += theme.fg("dim", ` · query:${input.query.join(",")}`); } if (input.close) { text += theme.fg("warning", " close"); } if (input.messages) { text += theme.fg("dim", " · messages"); } if (Array.isArray(input.at)) { text += theme.fg("dim", ` · at:${input.at.join(",")}`); } return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) { return new Text(theme.fg("warning", "running…"), 0, 0); } const details = result.details as RunDetails | undefined; if (!(details && "text" in details)) { const text = result.content?.[0]?.type === "text" ? result.content[0].text : "textual_run failed"; return new Text(theme.fg("error", text.slice(0, 500)), 0, 0); } return renderScreenBox(details, expanded, theme); }, }); }