From e536b9627e379de111baae0caf9862b8822bf4af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 3 Sep 2026 20:47:58 -0400 Subject: [PATCH] feat(tui): inspect live shell output (#47134) --- .../tui/src/component/dialog-shell-output.tsx | 144 ++++++++++++ packages/tui/src/config/keybind.ts | 1 + .../src/routes/session/composer/shell-tab.tsx | 26 ++- .../tui/test/cli/tui/composer-keymap.test.tsx | 36 ++- packages/tui/test/cli/tui/data.test.tsx | 8 +- .../component/dialog-shell-output.test.tsx | 221 ++++++++++++++++++ .../www/src/docs/content/cli/keybinds.mdx | 5 + script/drive/shell-output.ts | 88 +++++++ 8 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/component/dialog-shell-output.tsx create mode 100644 packages/tui/test/component/dialog-shell-output.test.tsx create mode 100644 script/drive/shell-output.ts diff --git a/packages/tui/src/component/dialog-shell-output.tsx b/packages/tui/src/component/dialog-shell-output.tsx new file mode 100644 index 00000000000..61c929255d3 --- /dev/null +++ b/packages/tui/src/component/dialog-shell-output.tsx @@ -0,0 +1,144 @@ +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { isShellNotFoundError, type LocationRef, type ShellInfo } from "@opencode-ai/client" +import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js" +import stripAnsi from "strip-ansi" +import { useClient } from "../context/client" +import { Keymap } from "../context/keymap" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" + +const PAGE_BYTES = 64 * 1024 + +export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) { + const client = useClient() + const dialog = useDialog() + const theme = useTheme("elevated") + const dimensions = useTerminalDimensions() + const [info, setInfo] = createSignal(props.shell) + const [output, setOutput] = createSignal() + const [omitted, setOmitted] = createSignal(false) + const [error, setError] = createSignal("") + const text = createMemo(() => stripAnsi(output() ?? "").replace(/\r\n?/g, "\n")) + const height = () => Math.max(3, Math.floor(dimensions().height * 0.6) - 6) + let scroll: ScrollBoxRenderable | undefined + + dialog.setSize("xlarge") + dialog.setCentered(true) + + createEffect(() => { + // The running-shell inventory drops exited commands. Keep this view tied to + // the opened ID and its original Location, not the list's current selection. + const id = props.shell.id + const location = { directory: props.location.directory, workspace: props.location.workspaceID } + let cursor: number | undefined + let disposed = false + let missing = false + let timer: ReturnType | undefined + + const load = async () => { + if (untrack(info).status === "running") { + const current = await client.api.shell.get({ id, location }) + if (disposed) return false + setInfo(current.data) + } + if (cursor === undefined) { + const head = await client.api.shell.output({ id, location, cursor: Number.MAX_SAFE_INTEGER }) + if (disposed) return false + cursor = Math.max(0, head.data.size - PAGE_BYTES) + setOmitted(cursor > 0) + } + const page = await client.api.shell.output({ id, location, cursor, limit: PAGE_BYTES }) + if (disposed) return false + cursor = page.data.cursor + setOutput((previous) => { + const next = (previous ?? "") + page.data.output + if (next.length > PAGE_BYTES) setOmitted(true) + return next.slice(-PAGE_BYTES) + }) + setError("") + return cursor < page.data.size + } + + const poll = () => { + void load() + .catch((cause: unknown) => { + if (disposed) return + missing = isShellNotFoundError(cause) + setError(missing ? "Shell output is no longer available." : "Unable to read shell output. Retrying…") + }) + .then((more) => { + // Poll only while the viewer is open, including after exit so the final + // file flush is observed. Never overlap reads or reload earlier pages. + if (!disposed && !missing) timer = setTimeout(poll, more ? 0 : 1_000) + }) + } + poll() + onCleanup(() => { + disposed = true + clearTimeout(timer) + }) + }) + + const status = () => { + if (info().status === "running") return "Running" + if (info().status === "timeout") return "Timed out" + if (info().status === "killed") return "Killed" + return info().exit === undefined ? "Exited" : `Exited · code ${info().exit}` + } + + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "up", title: "Scroll output up", group: "Shell", run: () => scroll?.scrollBy(-1) }, + { bind: "down", title: "Scroll output down", group: "Shell", run: () => scroll?.scrollBy(1) }, + { bind: "pageup", title: "Previous output page", group: "Shell", run: () => scroll?.scrollBy(-height()) }, + { bind: "pagedown", title: "Next output page", group: "Shell", run: () => scroll?.scrollBy(height()) }, + { bind: "home", title: "First loaded output", group: "Shell", run: () => scroll?.scrollTo(0) }, + { bind: "end", title: "Follow shell output", group: "Shell", run: () => scroll?.scrollTo(Infinity) }, + ], + })) + + return ( + + + + Shell output + + {status()} + dialog.clear()}> + esc + + + + {props.shell.command} + + + Earlier output omitted · showing recent output + + (scroll = value)} + height={height()} + stickyScroll + stickyStart="bottom" + scrollbarOptions={{ visible: false }} + > + + {text() || + (output() === undefined + ? "Loading output…" + : "No captured output. Output redirected to files is not shown here.")} + + + + {error()} + + + ↑/↓ scroll + end follow + esc back + + + ) +} diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 2cf6e767b06..264e09792f4 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -244,6 +244,7 @@ export const Definitions = { "composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"), "composer.shell.up": keybind("up", "Previous shell"), "composer.shell.down": keybind("down", "Next shell"), + "composer.shell.select": keybind("return", "View shell output"), "composer.shell.kill": keybind("ctrl+d", "Kill shell command"), "composer.terminal.up": keybind("up,k", "Previous terminal"), "composer.terminal.down": keybind("down,j", "Next terminal"), diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx index 81a8acd0882..0d46cfe4339 100644 --- a/packages/tui/src/routes/session/composer/shell-tab.tsx +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -6,6 +6,8 @@ import { useClient } from "../../../context/client" import { useTheme } from "../../../context/theme" import { Keymap } from "../../../context/keymap" import { useComposerTab } from "./index" +import { useDialog } from "../../../ui/dialog" +import { DialogShellOutput } from "../../../component/dialog-shell-output" export function ShellTab(props: { sessionID: string }) { const data = useData() @@ -13,6 +15,7 @@ export function ShellTab(props: { sessionID: string }) { const theme = useTheme() const composer = useComposerTab() const shortcuts = Keymap.useShortcuts() + const dialog = useDialog() const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"), @@ -23,6 +26,11 @@ export function ShellTab(props: { sessionID: string }) { const selectedEntry = createMemo(() => entries()[store.selected]) + const open = () => { + const entry = selectedEntry() + if (entry) dialog.replace(() => ) + } + createEffect(() => { if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1)) }) @@ -42,7 +50,13 @@ export function ShellTab(props: { sessionID: string }) { const cleanup = composer.register({ id: "shell", label: "Shell", - hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []), + hints: () => + selectedEntry() + ? [ + { label: "output", shortcut: shortcuts.get("composer.shell.select") ?? "" }, + { label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }, + ] + : [], }) onCleanup(cleanup) }) @@ -74,6 +88,12 @@ export function ShellTab(props: { sessionID: string }) { setStore("selected", (prev) => (prev + 1) % list.length) }, }, + { + id: "composer.shell.select", + title: "View shell output", + group: "Composer", + run: open, + }, { id: "composer.shell.kill", title: "Kill shell command", @@ -106,6 +126,10 @@ export function ShellTab(props: { sessionID: string }) { active() ? theme.background.action.primary.focused : theme.background.action.primary.default } onMouseOver={() => setStore("selected", index())} + onMouseUp={() => { + setStore("selected", index()) + open() + }} > () let closed = 0 let dispatch!: ReturnType["dispatch"] @@ -53,6 +56,13 @@ async function renderComposer( }) } const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1] + if (shellID && request.method === "GET") { + viewed.push(shellID) + return json({ location: { directory }, data: shells.find((shell) => shell.id === shellID) }) + } + if (url.pathname.endsWith("/output")) { + return json({ location: { directory }, data: { output: "", cursor: 0, size: 0, truncated: false } }) + } if (shellID && request.method === "DELETE") { removed.push(shellID) return new Response(null, { status: 204 }) @@ -100,7 +110,11 @@ async function renderComposer( ({}) }}> - + + + + + @@ -119,6 +133,7 @@ async function renderComposer( app, interrupted, removed, + viewed, route: () => route.data, dispatch: (command: string) => dispatch(command), closed: () => closed, @@ -154,15 +169,18 @@ test("disabled shell bindings have no component fallbacks", async () => { const composer = await renderComposer("shell", { "composer.shell.up": "none", "composer.shell.down": "none", + "composer.shell.select": "none", "composer.shell.kill": "none", }) try { expect(composer.app.captureCharFrame()).toContain("bun test") composer.app.mockInput.pressArrow("up") + composer.app.mockInput.pressEnter() composer.app.mockInput.pressKey("d", { ctrl: true }) await composer.app.renderOnce() expect(composer.closed()).toBe(0) expect(composer.removed).toEqual([]) + expect(composer.viewed).toEqual([]) composer.app.mockInput.pressArrow("down") composer.dispatch("composer.shell.kill") @@ -198,6 +216,22 @@ test("ctrl+c closes the active composer", async () => { } }) +test("shell output respects a configured binding with a focused textarea", async () => { + const composer = await renderComposer("shell", { "composer.shell.select": "ctrl+o" }, true) + try { + composer.app.mockInput.pressEnter() + await composer.app.renderOnce() + expect(composer.viewed).toEqual([]) + composer.app.mockInput.pressKey("o", { ctrl: true }) + await wait(() => composer.viewed.length > 0) + await composer.app.renderOnce() + expect(composer.app.captureCharFrame()).toContain("Shell output") + expect(composer.viewed).toEqual(["sh-a"]) + } finally { + composer.app.renderer.destroy() + } +}) + function session(id: string, title: string, parentID?: string) { return { id, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 7776514ea54..87f214f2bb4 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -15,6 +15,8 @@ import { LocationProvider, useLocation } from "../../../src/context/location" import { RouteProvider } from "../../../src/context/route" import { ThemeProvider } from "../../../src/context/theme" import { Composer } from "../../../src/routes/session/composer" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client" import { emptyThemeSource } from "../../fixture/fixture" @@ -2020,7 +2022,11 @@ test("keeps shell state scoped to location", async () => { - + + + + + diff --git a/packages/tui/test/component/dialog-shell-output.test.tsx b/packages/tui/test/component/dialog-shell-output.test.tsx new file mode 100644 index 00000000000..2912df24951 --- /dev/null +++ b/packages/tui/test/component/dialog-shell-output.test.tsx @@ -0,0 +1,221 @@ +/** @jsxImportSource @opentui/solid */ +import { ScrollBoxRenderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import type { ShellInfo } from "@opencode-ai/client" +import { expect, test } from "bun:test" +import { createSignal, onMount } from "solid-js" +import { ConfigProvider } from "../../src/config" +import { ClientProvider } from "../../src/context/client" +import { DataProvider, useData } from "../../src/context/data" +import { Keymap } from "../../src/context/keymap" +import { RouteProvider } from "../../src/context/route" +import { ThemeProvider } from "../../src/context/theme" +import { Composer } from "../../src/routes/session/composer" +import { DialogProvider } from "../../src/ui/dialog" +import { ToastProvider } from "../../src/ui/toast" +import { emptyThemeSource, tmpdir } from "../fixture/fixture" +import { createApi, createEventStream, createFetch, json } from "../fixture/tui-client" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +async function setup(width: number, output = "") { + const temporary = await tmpdir() + const location = { directory: `${temporary.path}/original`, workspaceID: "workspace_fixture" } + const shell: ShellInfo = { + id: "sh_fixture", + command: "render-scene --quality high", + cwd: location.directory, + shell: "/bin/sh", + file: `${temporary.path}/capture.out`, + status: "running", + metadata: { sessionID: "ses_fixture" }, + time: { started: 0 }, + } + const state = { output, missing: false, failure: false } + const requests: { url: URL; method: string }[] = [] + const events = createEventStream() + const envelope = (data: unknown) => json({ location, data }) + const api = createApi( + createFetch((url, request) => { + if (!url.pathname.startsWith("/api/shell")) return undefined + requests.push({ url, method: request.method }) + if (url.pathname === "/api/shell") return envelope([shell]) + if (state.missing) + return json({ _tag: "ShellNotFoundError", id: shell.id, message: "Shell not found" }, { status: 404 }) + if (state.failure) return new Response("Unavailable", { status: 503 }) + if (url.pathname === `/api/shell/${shell.id}`) return envelope(shell) + const bytes = Buffer.from(state.output) + const cursor = Math.min(Number(url.searchParams.get("cursor") ?? 0), bytes.length) + const end = Math.min(cursor + Number(url.searchParams.get("limit") ?? 65536), bytes.length) + return envelope({ + output: bytes.subarray(cursor, end).toString(), + cursor: end, + size: bytes.length, + truncated: false, + }) + }, events).fetch, + ) + + function Shells() { + const data = useData() + const [open, setOpen] = createSignal(true) + onMount(() => void data.shell.sync(location)) + return setOpen(false)} /> + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + ), + { width, height: 30, kittyKeyboard: true }, + ) + app.renderer.start() + await app.waitForFrame((frame) => frame.includes(shell.command)) + return { + ...app, + state, + shell, + location, + requests, + events, + async [Symbol.asyncDispose]() { + app.renderer.destroy() + await temporary[Symbol.asyncDispose]() + }, + } +} + +test.each([40, 100])("shell output opens, follows, scrolls, and survives exit at %s columns", async (width) => { + await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join("")) + expect(app.captureCharFrame()).toContain("output") + app.mockInput.pressEnter() + await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50")) + const scroll = app.renderer.root.findDescendantById("shell-output-scroll") + if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing") + expect(scroll.scrollTop).toBeGreaterThan(0) + + app.mockInput.pressKey("HOME") + await app.waitForFrame((frame) => frame.includes("Frame 1\n") || /Frame 1\s/.test(frame)) + expect(scroll.scrollTop).toBe(0) + app.state.output += "Frame 51\n" + await app.waitFor( + () => + app.requests.some( + (request) => request.url.searchParams.get("cursor") === String(Buffer.byteLength(app.state.output)), + ), + { maxPasses: 150 }, + ) + expect(scroll.scrollTop).toBe(0) + app.mockInput.pressKey("END") + await app.waitForFrame((frame) => frame.includes("Frame 51")) + app.shell.status = "exited" + app.shell.exit = 0 + app.events.emit({ + id: "evt_exit", + created: 0, + type: "shell.exited", + location: app.location, + data: { id: app.shell.id, exit: 0, status: "exited" }, + }) + await app.waitForFrame((frame) => frame.includes("code 0"), { maxPasses: 100 }) + const metadataReads = app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`).length + // Terminal metadata can arrive before the capture's final flush. + app.state.output += "\u001b[32mRender complete\u001b[0m\r\n" + await app.waitForFrame((frame) => frame.includes("Render complete") && frame.includes("code 0"), { maxPasses: 100 }) + expect(app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`)).toHaveLength( + metadataReads, + ) + expect(app.captureCharFrame()).not.toContain("[32m") + expect(app.requests.every((request) => request.method === "GET")).toBe(true) + const reads = app.requests.filter((request) => request.url.pathname !== "/api/shell") + expect(reads.every((request) => request.url.searchParams.get("location[directory]") === app.location.directory)).toBe( + true, + ) + expect( + reads.every((request) => request.url.searchParams.get("location[workspace]") === app.location.workspaceID), + ).toBe(true) + + app.mockInput.pressEscape() + await app.waitForFrame((frame) => !frame.includes("Shell output") && frame.includes("No shell commands")) + const count = app.requests.length + await Bun.sleep(1100) + expect(app.requests).toHaveLength(count) +}) + +test("empty output explains redirection, retries errors, and preserves output after removal", async () => { + await using app = await setup(100) + app.mockInput.pressEnter() + await app.waitForFrame((frame) => frame.includes("No captured output") && frame.includes("redirected")) + app.state.failure = true + await app.waitForFrame((frame) => frame.includes("Retrying"), { maxPasses: 100 }) + app.state.failure = false + app.state.output = "Recovered output\n" + await app.waitForFrame((frame) => frame.includes("Recovered output") && !frame.includes("Retrying"), { + maxPasses: 100, + }) + app.state.missing = true + await app.waitForFrame((frame) => frame.includes("no longer available"), { maxPasses: 100 }) + expect(app.captureCharFrame()).toContain("Recovered output") + const count = app.requests.length + await Bun.sleep(1100) + expect(app.requests).toHaveLength(count) +}) + +test.each([40, 100])("mouse-wheel scrolling pauses and resumes output following at %s columns", async (width) => { + await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join("")) + app.mockInput.pressEnter() + await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50")) + const scroll = app.renderer.root.findDescendantById("shell-output-scroll") + if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing") + const bottom = scroll.scrollTop + await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up") + await app.waitFor(() => scroll.scrollTop < bottom) + const paused = scroll.scrollTop + const height = scroll.scrollHeight + + app.state.output += "Frame 51\n" + await app.waitFor(() => scroll.scrollHeight > height, { maxPasses: 100 }) + expect(scroll.scrollTop).toBe(paused) + expect(app.captureCharFrame()).toContain("Shell output") + + await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down") + await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down") + await app.waitFor(() => scroll.scrollTop === scroll.scrollHeight - scroll.viewport.height) + const followed = scroll.scrollTop + app.state.output += "Frame 52\n" + await app.waitForFrame((frame) => frame.includes("Frame 52"), { maxPasses: 100 }) + expect(scroll.scrollTop).toBeGreaterThan(followed) + expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height) +}) + +test("large captures open at a bounded tail and clicking a shell opens the viewer", async () => { + await using app = await setup(100, "old output\n".repeat(20000) + "Latest frame\n") + const row = app + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes(app.shell.command)) + await app.mockMouse.click(6, row) + await app.waitForFrame((frame) => frame.includes("Latest frame") && frame.includes("Earlier output omitted")) + const reads = app.requests.filter((request) => request.url.pathname.endsWith("/output")) + expect(reads[0]?.url.searchParams.get("cursor")).toBe(String(Number.MAX_SAFE_INTEGER)) + expect(reads[1]?.url.searchParams.get("cursor")).toBe(String(Buffer.byteLength(app.state.output) - 65536)) + expect(reads[1]?.url.searchParams.get("limit")).toBe("65536") +}) diff --git a/packages/www/src/docs/content/cli/keybinds.mdx b/packages/www/src/docs/content/cli/keybinds.mdx index d51fadeb0c5..083cbc02ed6 100644 --- a/packages/www/src/docs/content/cli/keybinds.mdx +++ b/packages/www/src/docs/content/cli/keybinds.mdx @@ -317,8 +317,13 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an | `composer.subagent.interrupt` | `ctrl+d` | Interrupt subagent | | `composer.shell.up` | `up` | Previous shell | | `composer.shell.down` | `down` | Next shell | +| `composer.shell.select` | `return` | View shell output | | `composer.shell.kill` | `ctrl+d` | Kill shell command | +Select a running command in the **Shell** tab and press **Enter**, or click it, to view captured stdout and stderr. +Use **↑/↓**, **Page Up/Down**, or **Home** to scroll, **End** to follow new output, and **Esc** to return without stopping the command. +The viewer shows recent output and stays open after exit; output redirected to a file is not included. + ## Dialogs And Autocomplete | ID | Default | Description | diff --git a/script/drive/shell-output.ts b/script/drive/shell-output.ts new file mode 100644 index 00000000000..7fc1c04ab45 --- /dev/null +++ b/script/drive/shell-output.ts @@ -0,0 +1,88 @@ +import { Effect, Stream } from "effect" +import { Llm, OpenCodeDriver } from "opencode-drive" + +const label = process.env.DEMO_LABEL ?? "AFTER" + +// Run from the repository root with `opencode-drive run script/drive/shell-output.ts`. +// Set OPENCODE_DEV to an immutable base worktree and DEMO_LABEL=BEFORE for comparison. +// Only the conversation is simulated; shell execution and output reads are real. +export default OpenCodeDriver.use( + { + opencode: { dev: process.env.OPENCODE_DEV ?? process.cwd() }, + keepArtifacts: true, + tui: { recording: true, keypressOverlay: true, viewport: { cols: 90, rows: 30 } }, + config: { autoupdate: false, username: "Demo" }, + tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { enabled: false } }, + project: { + git: true, + files: { + "README.md": "# Shell output demo\nDeterministic real shell output.\n", + "render-scene.sh": [ + "#!/bin/sh", + "i=1", + 'while [ "$i" -le 40 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); done', + "while [ ! -f continue ]; do sleep 0.1; done", + 'while [ "$i" -le 48 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); sleep 0.25; done', + "while [ ! -f finish ]; do sleep 0.1; done", + "printf 'Diagnostics: no errors\\n' >&2", + "printf 'Render complete: 48 frames saved.\\n'", + ].join("\n"), + }, + }, + }, + ({ ui, llm, tui, opencode, artifacts }) => + Effect.gen(function* () { + const recording = tui.recording + if (!recording) return yield* Effect.fail(new Error("Recording required")) + yield* llm.serve(() => Stream.make(Llm.text("Ready to inspect the render job."))) + yield* ui.submit("Inspect the render job.") + yield* ui.waitFor("Ready to inspect the render job.") + const sessions = yield* opencode.session.list({ limit: 1, order: "desc" }) + const session = sessions.data[0] + if (!session) return yield* Effect.fail(new Error("Session missing")) + yield* opencode.session.rename({ sessionID: session.id, title: "Shell output demo" }) + yield* opencode.shell.create({ command: "sh render-scene.sh", timeout: 0, metadata: { sessionID: session.id } }) + yield* ui.arrow("down") + yield* ui.arrow("right") + yield* ui.waitFor("sh render-scene.sh") + yield* recording.mark(`${label}: select a running shell`) + yield* Effect.sleep(1000) + yield* ui.enter() + yield* ui.waitFor(label === "AFTER" ? "Frame 40: rendered successfully" : "sh render-scene.sh") + yield* Effect.sleep(1000) + yield* recording.mark(`${label}: Enter ${label === "AFTER" ? "opens live output" : "does nothing"}`) + console.log("opened:", yield* ui.screenshot(`${label.toLowerCase()}-opened`)) + yield* Effect.promise(() => Bun.write(`${artifacts}/files/continue`, "go")) + if (label === "AFTER") yield* ui.waitFor("Frame 48: rendered successfully") + yield* Effect.sleep(2800) + yield* ui.press("home") + yield* ui.waitFor(label === "AFTER" ? "Frame 01: rendered successfully" : "sh render-scene.sh") + yield* recording.mark(`${label}: ${label === "AFTER" ? "Home scrolls to earlier output" : "no output to scroll"}`) + yield* Effect.sleep(1500) + console.log("scrolled:", yield* ui.screenshot(`${label.toLowerCase()}-scrolled`)) + yield* ui.press("end") + yield* ui.waitFor(label === "AFTER" ? "Frame 48: rendered successfully" : "sh render-scene.sh") + yield* recording.mark(`${label}: ${label === "AFTER" ? "End follows the latest output" : "no output to follow"}`) + yield* Effect.sleep(1000) + yield* Effect.promise(() => Bun.write(`${artifacts}/files/finish`, "go")) + yield* ui.waitFor(label === "AFTER" ? "Render complete: 48 frames saved." : "No shell commands") + if (label === "AFTER") yield* ui.waitFor("Exited · code 0") + yield* Effect.sleep(1600) + yield* recording.mark( + `${label}: ${label === "AFTER" ? "result stays open after exit" : "finished shell disappears"}`, + ) + console.log("exited:", yield* ui.screenshot(`${label.toLowerCase()}-exited`)) + yield* Effect.sleep(2000) + yield* ui.resize({ cols: 40, rows: 24 }) + yield* Effect.sleep(500) + console.log("narrow:", yield* ui.screenshot(`${label.toLowerCase()}-narrow`)) + yield* ui.resize({ cols: 90, rows: 30 }) + yield* Effect.sleep(500) + yield* ui.press("escape") + if (label === "AFTER") yield* ui.waitFor("No shell commands") + yield* recording.mark(`${label}: Esc back`) + yield* Effect.sleep(1000) + console.log("back:", yield* ui.screenshot(`${label.toLowerCase()}-back`)) + return console.log("video:", yield* recording.finish()) + }), +)