Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/zoo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions apps/zoo/src/__tests__/interactive.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InteractiveSession
projection={projection}
actions={{ submit, approve: vi.fn(), cancel: vi.fn(), exit: vi.fn() }}
/>,
)

expect(view.lastFrame()).toContain("Approval required")
expect(view.lastFrame()).toContain("Write file?")
})
})
10 changes: 8 additions & 2 deletions apps/zoo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
172 changes: 172 additions & 0 deletions apps/zoo/src/interactive.tsx
Original file line number Diff line number Diff line change
@@ -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<SharedOptions, "cwd" | "timeout"> & { 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 (
<Box flexDirection="column" paddingX={1}>
<Box borderStyle="round" borderColor="cyan" paddingX={1} justifyContent="space-between">
<Text bold color="cyan">
Zoo Code
</Text>
<Text>{projection.rootTaskId ? `session ${projection.rootTaskId}` : "ready"}</Text>
</Box>
{[...projection.messages.entries()].map(([id, message]) => (
<Box key={id} marginTop={1} flexDirection="column">
<Text color={message.role === "reasoning" ? "gray" : "white"}>{message.role}</Text>
<Text wrap="wrap">{message.content}</Text>
</Box>
))}
{[...projection.tools.entries()].map(([id, tool]) => (
<Box
key={id}
borderStyle="single"
borderColor={tool.state === "failed" ? "red" : "yellow"}
paddingX={1}>
<Text>{`${tool.name} · ${tool.state}${tool.output ? ` · ${tool.output}` : ""}`}</Text>
</Box>
))}
{ask ? (
<Box borderStyle="round" borderColor="magenta" paddingX={1} flexDirection="column">
<Text bold>Approval required</Text>
<Text>{ask[1].subject}</Text>
<Text color="gray">Press y to approve once or n to reject</Text>
</Box>
) : null}
{projection.result ? (
<Text color={projection.result.success ? "green" : "red"}>{projection.result.outcome}</Text>
) : null}
<Box marginTop={1}>
<Text color="cyan">› </Text>
<Text>{input}</Text>
</Box>
<Text color="gray">Enter sends · Ctrl+C cancels · Ctrl+D exits when idle</Text>
</Box>
)
}

export async function runInteractive(initialPrompt: string | undefined, options: InteractiveOptions): Promise<number> {
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<ZooRunResult | undefined>((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 <InteractiveSession projection={state} actions={actions} />
}
const instance = render(<App />, { 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 },
)
}
2 changes: 1 addition & 1 deletion apps/zoo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
5 changes: 5 additions & 0 deletions apps/zoo/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
})
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading