mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 22:14:36 +00:00
feat(tui): inspect live shell output (#47134)
This commit is contained in:
parent
6af46cc8a9
commit
e536b9627e
8 changed files with 526 additions and 3 deletions
144
packages/tui/src/component/dialog-shell-output.tsx
Normal file
144
packages/tui/src/component/dialog-shell-output.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
221
packages/tui/test/component/dialog-shell-output.test.tsx
Normal file
221
packages/tui/test/component/dialog-shell-output.test.tsx
Normal 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")
|
||||
})
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
88
script/drive/shell-output.ts
Normal file
88
script/drive/shell-output.ts
Normal 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())
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue