Skip to content

Commit 46179b8

Browse files
committed
feat(cli): add interactive Zoo terminal client
1 parent c6511df commit 46179b8

7 files changed

Lines changed: 227 additions & 4 deletions

File tree

apps/zoo/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@
1919
},
2020
"dependencies": {
2121
"@roo-code/zoo-protocol": "workspace:^",
22-
"commander": "^12.1.0"
22+
"commander": "^12.1.0",
23+
"ink": "^6.6.0",
24+
"react": "^19.1.0"
2325
},
2426
"devDependencies": {
2527
"@roo-code/config-eslint": "workspace:^",
2628
"@roo-code/config-typescript": "workspace:^",
2729
"@types/node": "22.20.1",
30+
"@types/react": "18.3.31",
31+
"ink-testing-library": "4.0.0",
2832
"rimraf": "6.0.1",
2933
"tsup": "8.5.1",
3034
"vitest": "4.1.9"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { render } from "ink-testing-library"
2+
import { describe, expect, it, vi } from "vitest"
3+
4+
import { initialProjection } from "../projection.js"
5+
import { InteractiveSession } from "../interactive.js"
6+
7+
describe("InteractiveSession", () => {
8+
it("submits input and renders approval controls", () => {
9+
const submit = vi.fn()
10+
const projection = {
11+
...initialProjection(),
12+
pendingAsks: new Map([["ask-1", { taskId: "root", category: "tool", subject: "Write file?" }]]),
13+
}
14+
const view = render(
15+
<InteractiveSession
16+
projection={projection}
17+
actions={{ submit, approve: vi.fn(), cancel: vi.fn(), exit: vi.fn() }}
18+
/>,
19+
)
20+
21+
expect(view.lastFrame()).toContain("Approval required")
22+
expect(view.lastFrame()).toContain("Write file?")
23+
})
24+
})

apps/zoo/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ function normalized(options: SharedOptions & { format: OutputFormat; quiet: bool
4848
return { ...options, workspace: resolveWorkspace(options.cwd), timeout: parseDuration(options.timeout) }
4949
}
5050

51+
program.argument("[prompt...]")
52+
5153
automation(program.command("run [prompt...]").description("run one task without an interactive UI")).action(
5254
async (words: string[] | undefined, options: SharedOptions & { format: OutputFormat; quiet: boolean }) => {
5355
const positional = words?.join(" ").trim()
@@ -72,9 +74,13 @@ shared(
7274
await listSessions({ ...options, workspace: resolveWorkspace(options.cwd) })
7375
})
7476

75-
program.action(() => {
77+
program.action(async (words: string[] | undefined, options: SharedOptions) => {
7678
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive Zoo requires TTY stdin and stdout")
77-
throw new Error("Interactive Zoo is not available in this build")
79+
const { runInteractive } = await import("./interactive.js")
80+
process.exitCode = await runInteractive(words?.join(" ").trim(), {
81+
...options,
82+
workspace: resolveWorkspace(options.cwd ?? process.cwd()),
83+
})
7884
})
7985

8086
program.showSuggestionAfterError().showHelpAfterError()

apps/zoo/src/interactive.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import fs from "node:fs"
2+
import os from "node:os"
3+
import path from "node:path"
4+
import { fileURLToPath } from "node:url"
5+
6+
import { Box, Text, render, useInput } from "ink"
7+
import { useState } from "react"
8+
9+
import { exitCodeFor, type ZooRunResult } from "@roo-code/zoo-protocol"
10+
11+
import { runOverrides, type SharedOptions } from "./options.js"
12+
import { initialProjection, reduceSession, type SessionProjection } from "./projection.js"
13+
import { defaultStorageRoot, HostClient } from "./supervisor.js"
14+
15+
type InteractiveOptions = Omit<SharedOptions, "cwd" | "timeout"> & { workspace: string }
16+
17+
type Actions = {
18+
submit: (text: string) => void
19+
approve: (approve: boolean) => void
20+
cancel: () => void
21+
exit: () => void
22+
}
23+
24+
export function InteractiveSession({ projection, actions }: { projection: SessionProjection; actions: Actions }) {
25+
const [input, setInput] = useState("")
26+
const ask = [...projection.pendingAsks.entries()][0]
27+
28+
useInput((value, key) => {
29+
if (key.ctrl && value === "c") return actions.cancel()
30+
if (key.ctrl && value === "d" && input.length === 0) return actions.exit()
31+
if (ask && (value.toLowerCase() === "y" || value.toLowerCase() === "n")) {
32+
actions.approve(value.toLowerCase() === "y")
33+
return
34+
}
35+
if (key.return) {
36+
if (input.trim()) actions.submit(input.trim())
37+
setInput("")
38+
return
39+
}
40+
if (key.backspace || key.delete) return setInput((current) => current.slice(0, -1))
41+
if (!key.ctrl && !key.meta && value) setInput((current) => current + value)
42+
})
43+
44+
return (
45+
<Box flexDirection="column" paddingX={1}>
46+
<Box borderStyle="round" borderColor="cyan" paddingX={1} justifyContent="space-between">
47+
<Text bold color="cyan">
48+
Zoo Code
49+
</Text>
50+
<Text>{projection.rootTaskId ? `session ${projection.rootTaskId}` : "ready"}</Text>
51+
</Box>
52+
{[...projection.messages.entries()].map(([id, message]) => (
53+
<Box key={id} marginTop={1} flexDirection="column">
54+
<Text color={message.role === "reasoning" ? "gray" : "white"}>{message.role}</Text>
55+
<Text wrap="wrap">{message.content}</Text>
56+
</Box>
57+
))}
58+
{[...projection.tools.entries()].map(([id, tool]) => (
59+
<Box
60+
key={id}
61+
borderStyle="single"
62+
borderColor={tool.state === "failed" ? "red" : "yellow"}
63+
paddingX={1}>
64+
<Text>{`${tool.name} · ${tool.state}${tool.output ? ` · ${tool.output}` : ""}`}</Text>
65+
</Box>
66+
))}
67+
{ask ? (
68+
<Box borderStyle="round" borderColor="magenta" paddingX={1} flexDirection="column">
69+
<Text bold>Approval required</Text>
70+
<Text>{ask[1].subject}</Text>
71+
<Text color="gray">Press y to approve once or n to reject</Text>
72+
</Box>
73+
) : null}
74+
{projection.result ? (
75+
<Text color={projection.result.success ? "green" : "red"}>{projection.result.outcome}</Text>
76+
) : null}
77+
<Box marginTop={1}>
78+
<Text color="cyan"></Text>
79+
<Text>{input}</Text>
80+
</Box>
81+
<Text color="gray">Enter sends · Ctrl+C cancels · Ctrl+D exits when idle</Text>
82+
</Box>
83+
)
84+
}
85+
86+
export async function runInteractive(initialPrompt: string | undefined, options: InteractiveOptions): Promise<number> {
87+
const storageRoot = options.ephemeral
88+
? fs.mkdtempSync(path.join(os.tmpdir(), "zoo-"))
89+
: path.join(defaultStorageRoot(), "state")
90+
fs.mkdirSync(storageRoot, { recursive: true })
91+
let projection = initialProjection()
92+
let update: ((projection: SessionProjection) => void) | undefined
93+
let rootTaskId: string | undefined
94+
let currentTaskId: string | undefined
95+
let settle: ((result: ZooRunResult | undefined) => void) | undefined
96+
const settled = new Promise<ZooRunResult | undefined>((resolve) => (settle = resolve))
97+
const client = new HostClient({
98+
workspace: options.workspace,
99+
storageRoot,
100+
extensionRoot: process.env.ZOO_EXTENSION_PATH ?? fileURLToPath(new URL("../../../src/dist", import.meta.url)),
101+
debug: options.debug,
102+
onEvent(event) {
103+
projection = reduceSession(projection, event)
104+
currentTaskId = projection.currentTaskId
105+
update?.(projection)
106+
if (event.type === "task.result") settle?.(event.result)
107+
},
108+
})
109+
110+
await client.start()
111+
let starting = false
112+
const actions: Actions = {
113+
submit(text) {
114+
if (!rootTaskId && !starting) {
115+
starting = true
116+
void client
117+
.command({
118+
type: "task.start",
119+
workspace: options.workspace,
120+
prompt: text,
121+
overrides: runOverrides({ ...options, approval: "interactive" }),
122+
})
123+
.then((response) => {
124+
if (response.data.commandType === "task.start") rootTaskId = response.data.task.rootTaskId
125+
})
126+
.catch(() => settle?.(undefined))
127+
return
128+
}
129+
if (currentTaskId) void client.command({ type: "task.input", taskId: currentTaskId, text })
130+
},
131+
approve(approve) {
132+
const pending = [...projection.pendingAsks.entries()][0]
133+
if (!pending) return
134+
void client.command({
135+
type: "ask.respond",
136+
taskId: pending[1].taskId,
137+
askId: pending[0],
138+
response: approve ? "approve" : "reject",
139+
})
140+
},
141+
cancel() {
142+
if (rootTaskId) void client.command({ type: "task.cancel", rootTaskId, reason: "user" })
143+
else settle?.(undefined)
144+
},
145+
exit: () => settle?.(undefined),
146+
}
147+
const App = () => {
148+
const [state, setState] = useState(projection)
149+
update = setState
150+
return <InteractiveSession projection={state} actions={actions} />
151+
}
152+
const instance = render(<App />, { exitOnCtrlC: false })
153+
if (initialPrompt) actions.submit(initialPrompt)
154+
const result = await settled
155+
instance.unmount()
156+
await client.stop()
157+
if (options.ephemeral) fs.rmSync(storageRoot, { recursive: true, force: true })
158+
if (!result) return 0
159+
const failedCode =
160+
result.error?.code === "task_timed_out" || result.error?.code === "cleanup_timed_out"
161+
? "task_failed"
162+
: (result.error?.code ?? "task_failed")
163+
return exitCodeFor(
164+
result.outcome === "failed"
165+
? { outcome: "failed", errorCode: failedCode }
166+
: result.outcome === "cancelled"
167+
? { outcome: "cancelled" }
168+
: result.outcome === "timed_out"
169+
? { outcome: "timed_out" }
170+
: { outcome: result.outcome },
171+
)
172+
}

apps/zoo/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"extends": "@roo-code/config-typescript/base.json",
3-
"compilerOptions": { "outDir": "dist" },
3+
"compilerOptions": { "outDir": "dist", "jsx": "react-jsx" },
44
"include": ["src", "*.config.ts"],
55
"exclude": ["node_modules"]
66
}

apps/zoo/tsup.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ export default defineConfig({
99
platform: "node",
1010
banner: { js: "#!/usr/bin/env node" },
1111
noExternal: ["@roo-code/zoo-protocol"],
12+
external: ["react-devtools-core"],
13+
esbuildOptions(options) {
14+
options.jsx = "automatic"
15+
options.jsxImportSource = "react"
16+
},
1217
})

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)