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