|
| 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 | +} |
0 commit comments