-
Notifications
You must be signed in to change notification settings - Fork 232
feat(cli): add interactive Zoo terminal client #1160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: fm/zoo-cli-automation
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test renders the component but never drives |
||
| 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?") | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive Zoo requires TTY stdin and stdout") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With this TTY guard in place and no interactive subprocess test, the default |
||
| 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() | ||
|
|
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
| 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)), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is |
||
| 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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If |
||
| return | ||
| } | ||
| if (currentTaskId) void client.command({ type: "task.input", taskId: currentTaskId, text }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These follow-up |
||
| }, | ||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If Ctrl+C lands between |
||
| }, | ||
| exit: () => settle?.(undefined), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens if the host process dies after |
||
| instance.unmount() | ||
| await client.stop() | ||
| if (options.ephemeral) fs.rmSync(storageRoot, { recursive: true, force: true }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If |
||
| 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 }, | ||
| ) | ||
|
Comment on lines
+159
to
+171
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
| } | ||
| 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"] | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not match the react version v19?