diff --git a/apps/zoo/package.json b/apps/zoo/package.json index b6fb82ce6b..e045b2e628 100644 --- a/apps/zoo/package.json +++ b/apps/zoo/package.json @@ -19,12 +19,16 @@ }, "dependencies": { "@roo-code/zoo-protocol": "workspace:^", - "commander": "^12.1.0" + "commander": "^12.1.0", + "ink": "^6.6.0", + "react": "^19.1.0" }, "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@types/node": "22.20.1", + "@types/react": "18.3.31", + "ink-testing-library": "4.0.0", "rimraf": "6.0.1", "tsup": "8.5.1", "vitest": "4.1.9" diff --git a/apps/zoo/src/__tests__/interactive.test.tsx b/apps/zoo/src/__tests__/interactive.test.tsx new file mode 100644 index 0000000000..86d65ca8d6 --- /dev/null +++ b/apps/zoo/src/__tests__/interactive.test.tsx @@ -0,0 +1,24 @@ +import { render } from "ink-testing-library" +import { describe, expect, it, vi } from "vitest" + +import { initialProjection } from "../projection.js" +import { InteractiveSession } from "../interactive.js" + +describe("InteractiveSession", () => { + it("submits input and renders approval controls", () => { + const submit = vi.fn() + const projection = { + ...initialProjection(), + pendingAsks: new Map([["ask-1", { taskId: "root", category: "tool", subject: "Write file?" }]]), + } + const view = render( + , + ) + + expect(view.lastFrame()).toContain("Approval required") + expect(view.lastFrame()).toContain("Write file?") + }) +}) diff --git a/apps/zoo/src/index.ts b/apps/zoo/src/index.ts index 95c9456dc6..3b314d2832 100644 --- a/apps/zoo/src/index.ts +++ b/apps/zoo/src/index.ts @@ -48,6 +48,8 @@ function normalized(options: SharedOptions & { format: OutputFormat; quiet: bool return { ...options, workspace: resolveWorkspace(options.cwd), timeout: parseDuration(options.timeout) } } +program.argument("[prompt...]") + automation(program.command("run [prompt...]").description("run one task without an interactive UI")).action( async (words: string[] | undefined, options: SharedOptions & { format: OutputFormat; quiet: boolean }) => { const positional = words?.join(" ").trim() @@ -72,9 +74,13 @@ shared( await listSessions({ ...options, workspace: resolveWorkspace(options.cwd) }) }) -program.action(() => { +program.action(async (words: string[] | undefined, options: SharedOptions) => { if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive Zoo requires TTY stdin and stdout") - throw new Error("Interactive Zoo is not available in this build") + const { runInteractive } = await import("./interactive.js") + process.exitCode = await runInteractive(words?.join(" ").trim(), { + ...options, + workspace: resolveWorkspace(options.cwd ?? process.cwd()), + }) }) program.showSuggestionAfterError().showHelpAfterError() diff --git a/apps/zoo/src/interactive.tsx b/apps/zoo/src/interactive.tsx new file mode 100644 index 0000000000..0e821a35e4 --- /dev/null +++ b/apps/zoo/src/interactive.tsx @@ -0,0 +1,172 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { Box, Text, render, useInput } from "ink" +import { useState } from "react" + +import { exitCodeFor, type ZooRunResult } from "@roo-code/zoo-protocol" + +import { runOverrides, type SharedOptions } from "./options.js" +import { initialProjection, reduceSession, type SessionProjection } from "./projection.js" +import { defaultStorageRoot, HostClient } from "./supervisor.js" + +type InteractiveOptions = Omit & { workspace: string } + +type Actions = { + submit: (text: string) => void + approve: (approve: boolean) => void + cancel: () => void + exit: () => void +} + +export function InteractiveSession({ projection, actions }: { projection: SessionProjection; actions: Actions }) { + const [input, setInput] = useState("") + const ask = [...projection.pendingAsks.entries()][0] + + useInput((value, key) => { + if (key.ctrl && value === "c") return actions.cancel() + if (key.ctrl && value === "d" && input.length === 0) return actions.exit() + if (ask && (value.toLowerCase() === "y" || value.toLowerCase() === "n")) { + actions.approve(value.toLowerCase() === "y") + return + } + if (key.return) { + if (input.trim()) actions.submit(input.trim()) + setInput("") + return + } + if (key.backspace || key.delete) return setInput((current) => current.slice(0, -1)) + if (!key.ctrl && !key.meta && value) setInput((current) => current + value) + }) + + return ( + + + + Zoo Code + + {projection.rootTaskId ? `session ${projection.rootTaskId}` : "ready"} + + {[...projection.messages.entries()].map(([id, message]) => ( + + {message.role} + {message.content} + + ))} + {[...projection.tools.entries()].map(([id, tool]) => ( + + {`${tool.name} · ${tool.state}${tool.output ? ` · ${tool.output}` : ""}`} + + ))} + {ask ? ( + + Approval required + {ask[1].subject} + Press y to approve once or n to reject + + ) : null} + {projection.result ? ( + {projection.result.outcome} + ) : null} + + + {input} + + Enter sends · Ctrl+C cancels · Ctrl+D exits when idle + + ) +} + +export async function runInteractive(initialPrompt: string | undefined, options: InteractiveOptions): Promise { + const storageRoot = options.ephemeral + ? fs.mkdtempSync(path.join(os.tmpdir(), "zoo-")) + : path.join(defaultStorageRoot(), "state") + fs.mkdirSync(storageRoot, { recursive: true }) + let projection = initialProjection() + let update: ((projection: SessionProjection) => void) | undefined + let rootTaskId: string | undefined + let currentTaskId: string | undefined + let settle: ((result: ZooRunResult | undefined) => void) | undefined + const settled = new Promise((resolve) => (settle = resolve)) + const client = new HostClient({ + workspace: options.workspace, + storageRoot, + extensionRoot: process.env.ZOO_EXTENSION_PATH ?? fileURLToPath(new URL("../../../src/dist", import.meta.url)), + debug: options.debug, + onEvent(event) { + projection = reduceSession(projection, event) + currentTaskId = projection.currentTaskId + update?.(projection) + if (event.type === "task.result") settle?.(event.result) + }, + }) + + await client.start() + let starting = false + const actions: Actions = { + submit(text) { + if (!rootTaskId && !starting) { + starting = true + void client + .command({ + type: "task.start", + workspace: options.workspace, + prompt: text, + overrides: runOverrides({ ...options, approval: "interactive" }), + }) + .then((response) => { + if (response.data.commandType === "task.start") rootTaskId = response.data.task.rootTaskId + }) + .catch(() => settle?.(undefined)) + return + } + if (currentTaskId) void client.command({ type: "task.input", taskId: currentTaskId, text }) + }, + approve(approve) { + const pending = [...projection.pendingAsks.entries()][0] + if (!pending) return + void client.command({ + type: "ask.respond", + taskId: pending[1].taskId, + askId: pending[0], + response: approve ? "approve" : "reject", + }) + }, + cancel() { + if (rootTaskId) void client.command({ type: "task.cancel", rootTaskId, reason: "user" }) + else settle?.(undefined) + }, + exit: () => settle?.(undefined), + } + const App = () => { + const [state, setState] = useState(projection) + update = setState + return + } + const instance = render(, { exitOnCtrlC: false }) + if (initialPrompt) actions.submit(initialPrompt) + const result = await settled + instance.unmount() + await client.stop() + if (options.ephemeral) fs.rmSync(storageRoot, { recursive: true, force: true }) + if (!result) return 0 + const failedCode = + result.error?.code === "task_timed_out" || result.error?.code === "cleanup_timed_out" + ? "task_failed" + : (result.error?.code ?? "task_failed") + return exitCodeFor( + result.outcome === "failed" + ? { outcome: "failed", errorCode: failedCode } + : result.outcome === "cancelled" + ? { outcome: "cancelled" } + : result.outcome === "timed_out" + ? { outcome: "timed_out" } + : { outcome: result.outcome }, + ) +} diff --git a/apps/zoo/tsconfig.json b/apps/zoo/tsconfig.json index 99027cfa10..3eee16f883 100644 --- a/apps/zoo/tsconfig.json +++ b/apps/zoo/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@roo-code/config-typescript/base.json", - "compilerOptions": { "outDir": "dist" }, + "compilerOptions": { "outDir": "dist", "jsx": "react-jsx" }, "include": ["src", "*.config.ts"], "exclude": ["node_modules"] } diff --git a/apps/zoo/tsup.config.ts b/apps/zoo/tsup.config.ts index f016edb917..8007eca38a 100644 --- a/apps/zoo/tsup.config.ts +++ b/apps/zoo/tsup.config.ts @@ -9,4 +9,9 @@ export default defineConfig({ platform: "node", banner: { js: "#!/usr/bin/env node" }, noExternal: ["@roo-code/zoo-protocol"], + external: ["react-devtools-core"], + esbuildOptions(options) { + options.jsx = "automatic" + options.jsxImportSource = "react" + }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15c5765782..5ef7fdb4ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,12 @@ importers: commander: specifier: ^12.1.0 version: 12.1.0 + ink: + specifier: ^6.6.0 + version: 6.6.0(@types/react@18.3.31)(react@19.2.7) + react: + specifier: ^19.1.0 + version: 19.2.7 devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -213,6 +219,12 @@ importers: '@types/node': specifier: 22.20.1 version: 22.20.1 + '@types/react': + specifier: 18.3.31 + version: 18.3.31 + ink-testing-library: + specifier: 4.0.0 + version: 4.0.0(@types/react@18.3.31) rimraf: specifier: 6.0.1 version: 6.0.1