feat(tui): inspect live shell output (#47134)

This commit is contained in:
Kit Langton 2026-09-03 20:47:58 -04:00 committed by GitHub
parent 6af46cc8a9
commit e536b9627e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 526 additions and 3 deletions

View file

@ -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<string>()
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<typeof setTimeout> | 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 (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" gap={2}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
Shell output
</text>
<text fg={theme.text.subdued}>{status()}</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} maxHeight={3} wrapMode="word">
{props.shell.command}
</text>
<Show when={omitted()}>
<text fg={theme.text.subdued}>Earlier output omitted · showing recent output</text>
</Show>
<scrollbox
id="shell-output-scroll"
ref={(value: ScrollBoxRenderable) => (scroll = value)}
height={height()}
stickyScroll
stickyStart="bottom"
scrollbarOptions={{ visible: false }}
>
<text fg={theme.text.default} wrapMode="word">
{text() ||
(output() === undefined
? "Loading output…"
: "No captured output. Output redirected to files is not shown here.")}
</text>
</scrollbox>
<Show when={error()}>
<text fg={theme.text.feedback.error.default}>{error()}</text>
</Show>
<box flexDirection="row" gap={2} flexWrap="wrap">
<text fg={theme.text.subdued}>/ scroll</text>
<text fg={theme.text.subdued}>end follow</text>
<text fg={theme.text.subdued}>esc back</text>
</box>
</box>
)
}

View file

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

View file

@ -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(() => <DialogShellOutput shell={entry} location={entry.location} />)
}
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()
}}
>
<text
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}

View file

@ -11,6 +11,8 @@ import { LocationProvider } from "../../../src/context/location"
import { RouteProvider, useRoute } 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 { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@ -31,6 +33,7 @@ async function renderComposer(
const events = createEventStream()
const interrupted: string[] = []
const removed: string[] = []
const viewed: string[] = []
const ready = Promise.withResolvers<void>()
let closed = 0
let dispatch!: ReturnType<typeof Keymap.use>["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(
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Content />
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</RouteProvider>
</LocationProvider>
@ -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,

View file

@ -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 () => {
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
<ToastProvider>
<DialogProvider>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</RouteProvider>

View file

@ -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 <Composer sessionID="ses_fixture" open={open()} defaultTab="shell" onClose={() => setOpen(false)} />
}
const app = await testRender(
() => (
<TestTuiContexts directory={temporary.path} paths={{ state: temporary.path }}>
<ConfigProvider config={createTuiResolvedConfig({ session: { terminal: false } })}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
<ClientProvider api={api}>
<DataProvider directory={temporary.path}>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<Shells />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ 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")
})

View file

@ -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 |

View file

@ -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())
}),
)