Skip to content

feat(cli): add interactive Zoo terminal client - #1160

Open
taltas wants to merge 1 commit into
fm/zoo-cli-automationfrom
fm/zoo-cli-interactive
Open

feat(cli): add interactive Zoo terminal client#1160
taltas wants to merge 1 commit into
fm/zoo-cli-automationfrom
fm/zoo-cli-interactive

Conversation

@taltas

@taltas taltas commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Stack

Position 5 of 6 in the Zoo CLI stack.

  • Parent/base: fm/zoo-cli-automation (PR feat(cli): add Zoo automation client #1159)
  • Child: fm/zoo-cli-release-docs (planned)
  • Native local ancestry is preserved; GitHub uses the recorded classic fallback because gh stack submit reports stacked PRs are not enabled.

Scope

  • Adds a focused Ink terminal frontend over the same pure SessionProjection used by automation.
  • Supports initial prompts, follow-up input, approve-once/reject actions, cancellation, and idle exit.
  • Renders ordered assistant/reasoning messages, tool state, approval cards, session status, and final outcome.
  • Dynamically loads Ink only for the interactive root command so automation output and signal behavior remain isolated.
  • Keeps all runtime/task behavior in the supervised production extension host.

Acceptance Evidence

  • pnpm --dir apps/zoo test (10 tests, including Ink rendering and packaged subprocess automation)
  • pnpm --dir apps/zoo check-types
  • Targeted suppression-pruning ESLint
  • Repository pre-commit lint and pre-push typecheck

Risk

Terminal input is intentionally focused rather than a broad control protocol. Ink is dynamically imported so its signal handling cannot affect run, JSON, or NDJSON automation paths.

flowchart LR
    U[TTY user] --> I[Ink client]
    I --> A[Input and approval actions]
    A --> H[Supervised zoo-host]
    H --> E[Production extension]
    E --> N[Normalized events]
    N --> R[SessionProjection reducer]
    R --> I
Loading

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d989eefc-dcec-4bd9-9b86-b00ccdf52a79

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@taltas
taltas force-pushed the fm/zoo-cli-interactive branch from 53e11e2 to 82307ab Compare August 5, 2026 18:01
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from dc868c1 to 270bd8f Compare August 5, 2026 18:01
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch from 82307ab to a350044 Compare August 5, 2026 18:02
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from a34840d to c6511df Compare August 5, 2026 18:09
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch from a350044 to 46179b8 Compare August 5, 2026 18:09
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from c6511df to cb131fc Compare August 5, 2026 18:20
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch 3 times, most recently from f5dee31 to 77c038d Compare August 5, 2026 18:52
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from e45bf88 to 04e2307 Compare August 5, 2026 18:52
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@taltas
taltas force-pushed the fm/zoo-cli-automation branch from 04e2307 to 7f0cd10 Compare August 5, 2026 19:06
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch 2 times, most recently from 48e5924 to ea45605 Compare August 5, 2026 19:30
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from 7f0cd10 to 0ac5595 Compare August 5, 2026 19:30
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch 2 times, most recently from 9c3ac7f to d6d57d0 Compare August 5, 2026 19:51
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from 3325a7a to eb180c7 Compare August 5, 2026 19:51
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch from d6d57d0 to 571b4a2 Compare August 5, 2026 20:07
@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Aug 5, 2026
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from 1fc922e to c47d0c5 Compare August 5, 2026 21:33
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch from 571b4a2 to 5b85926 Compare August 5, 2026 21:33
@taltas
taltas force-pushed the fm/zoo-cli-interactive branch from 5b85926 to e4fcdea Compare August 5, 2026 21:37
@taltas
taltas force-pushed the fm/zoo-cli-automation branch from c47d0c5 to e122a32 Compare August 5, 2026 21:37
@github-actions github-actions Bot added awaiting-review PR changes are ready and waiting for maintainer re-review and removed awaiting-review PR changes are ready and waiting for maintainer re-review labels Aug 5, 2026
}
const instance = render(<App />, { exitOnCtrlC: false })
if (initialPrompt) actions.submit(initialPrompt)
const result = await settled

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the host process dies after task.start succeeds but before a task.result event arrives? client.failed is never raced against this promise, so on a host crash (watchdog timeout, SIGKILL) settle() is never called and the CLI hangs here with no escape. automation.ts races client.failed.catch(...) in a Promise.race — should the interactive path do the same?

.then((response) => {
if (response.data.commandType === "task.start") rootTaskId = response.data.task.rootTaskId
})
.catch(() => settle?.(undefined))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If task.start rejects (bad credentials, host error, crash while the start is pending), this .catch settles with undefined, which runInteractive maps to exit 0 at line 158 — a task that never launched reports success, with no stderr message. Should this surface as a non-zero exit, or reset starting so the user can retry?

Comment on lines +159 to +171
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 },
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This failedCode mapping mirrors automation.ts:155-173, and the two copies already diverge — automation passes a signal field for the cancelled outcome and keeps a timed-out sub-branch, this one omits both. Could this be a shared helper so the exit-code logic can't drift?

Comment thread apps/zoo/src/index.ts
})

program.action(() => {
program.action(async (words: string[] | undefined, options: SharedOptions) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shared() is applied to the subcommands but not the root program, so none of --provider/--model/--approval/--ephemeral/--debug are registered here — at runtime options is {} and zoo --ephemeral "prompt" throws "unknown option". Should the root program call shared(program) before .action(...)?

const client = new HostClient({
workspace: options.workspace,
storageRoot,
extensionRoot: process.env.ZOO_EXTENSION_PATH ?? fileURLToPath(new URL("../../../src/dist", import.meta.url)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ZOO_EXTENSION_PATH documented anywhere? It's the only way to point the CLI at a dev or custom extension root, but I don't see it in a README or .env.example.

.catch(() => settle?.(undefined))
return
}
if (currentTaskId) void client.command({ type: "task.input", taskId: currentTaskId, text })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These follow-up client.command(...) calls (here and at ask.respond / cancel) have no .catch() — if the host dies, supervisor.ts rejects every pending command and this surfaces as an unhandled rejection, or a silently lost follow-up. Intentional?

const result = await settled
instance.unmount()
await client.stop()
if (options.ephemeral) fs.rmSync(storageRoot, { recursive: true, force: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If client.start() throws at line 110, control leaves runInteractive before this rmSync — the ephemeral mkdtemp directory leaks. automation.ts wraps its cleanup in a finally; should this match that pattern?

let projection = initialProjection()
let update: ((projection: SessionProjection) => void) | undefined
let rootTaskId: string | undefined
let currentTaskId: string | undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currentTaskId is overwritten with projection.currentTaskId on every event and only read at line 129, so it always equals the projection field. Could the reads just use projection.currentTaskId?

Comment thread apps/zoo/src/index.ts

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 zoo path (onEvent → settle → unmount → exitCodeFor) has no packaged/CLI-layer coverage — subprocess.test.ts covers run/resume/sessions only. Worth a ZOO_FORCE_INTERACTIVE bypass and a fake-host subprocess test?

Comment thread apps/zoo/package.json
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "22.20.1",
"@types/react": "18.3.31",

Copy link
Copy Markdown
Contributor

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?

@github-actions github-actions Bot added awaiting-author PR is waiting for the author to address requested changes and removed awaiting-review PR changes are ready and waiting for maintainer re-review labels Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-author PR is waiting for the author to address requested changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants