feat(tui): stream shell tool output

This commit is contained in:
Dax Raad
2026-07-14 21:29:59 -04:00
parent 9a25673c37
commit 387bff8fd9
3 changed files with 100 additions and 16 deletions
+32 -11
View File
@@ -3,7 +3,7 @@ export * as ShellTool from "./shell"
import path from "path" import path from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect" import { Effect, Fiber, Schedule, Schema, Scope } from "effect"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
@@ -72,7 +72,6 @@ const modelOutput = (output: Output): string | undefined => {
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection. // TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows. // TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist. // TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
// TODO: Persist job status and define restart recovery before exposing remote observation. // TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined. // TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
@@ -201,9 +200,18 @@ export const Plugin = {
metadata: { sessionID: context.sessionID }, metadata: { sessionID: context.sessionID },
}) })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
const truncated = page.size > page.cursor
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id) const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") { if (final.status === "timeout") {
return { return {
@@ -215,13 +223,11 @@ export const Plugin = {
} }
} }
const truncated = page.size > page.cursor const capture = yield* captureShell()
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return { return {
exit: final.exit, exit: final.exit,
output: `${body}${notice}`, output: capture.output,
truncated, truncated: capture.truncated,
status: "completed" as const, status: "completed" as const,
} }
}) })
@@ -250,9 +256,24 @@ export const Plugin = {
} }
} }
const result = yield* runtime.job const progress = yield* Effect.sleep("1 second").pipe(
.block({ id: job.id, sessionID: context.sessionID }) Effect.andThen(
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) captureShell().pipe(
Effect.flatMap((capture) =>
context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
}),
),
),
),
Effect.repeat(Schedule.forever),
Effect.forkIn(scope, { startImmediately: true }),
)
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
Effect.ensuring(Fiber.interrupt(progress)),
)
if (result?.type === "backgrounded") { if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0) yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.callID, input.command) yield* notifyWhenDone(context.sessionID, context.callID, input.command)
+33 -1
View File
@@ -166,6 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -413,6 +417,35 @@ describe("ShellTool", () => {
), ),
) )
it.live("reports bounded output progress for a running command", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)),
})
expect(progress).toHaveLength(1)
expect(progress[0]?.structured).toEqual({ truncated: true })
const content = progress[0]?.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("returns a useful timeout settlement", () => it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
@@ -572,7 +605,6 @@ test("keeps locked deferred parity TODOs visible", async () => {
"Replace token-based command-argument external-directory advisories with parser-based detection.", "Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.", "Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.", "Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist job status and define restart recovery before exposing remote observation.", "Persist job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.", "Revisit binary output handling if stdout/stderr decoding is text-only.",
+35 -4
View File
@@ -2322,6 +2322,7 @@ function BlockTool(props: {
function Shell(props: ToolProps) { function Shell(props: ToolProps) {
const { theme } = useTheme() const { theme } = useTheme()
const ctx = use() const ctx = use()
const client = useClient()
const data = useData() const data = useData()
const permission = createMemo(() => { const permission = createMemo(() => {
const request = data.session.permission.list(ctx.sessionID)?.[0] const request = data.session.permission.list(ctx.sessionID)?.[0]
@@ -2335,13 +2336,37 @@ function Shell(props: ToolProps) {
}) })
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning()) const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
const command = createMemo(() => stringValue(props.input.command)) const command = createMemo(() => stringValue(props.input.command))
const [expanded, setExpanded] = createSignal(false)
const [backgroundOutput, setBackgroundOutput] = createSignal("")
let loading = false
const loadBackgroundOutput = async () => {
const id = shellID()
if (!id || loading) return
loading = true
const location = data.session.get(ctx.sessionID)?.location
await client.api.shell
.output({
id,
limit: 1024 * 1024,
location: location
? { directory: location.directory, workspace: location.workspaceID }
: undefined,
})
.then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim())))
.catch(() => undefined)
loading = false
}
createEffect(() => {
if (!expanded() || !backgroundRunning()) return
const interval = setInterval(() => void loadBackgroundOutput(), 1_000)
onCleanup(() => clearInterval(interval))
})
const output = createMemo(() => { const output = createMemo(() => {
if (props.part.state.status === "streaming") return "" if (props.part.state.status === "streaming") return ""
if (shellID()) return "" if (shellID()) return expanded() ? backgroundOutput() : ""
const content = props.part.state.content[0] const content = props.part.state.content[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "") return stripAnsi(content?.type === "text" ? content.text.trim() : "")
}) })
const [expanded, setExpanded] = createSignal(false)
const maxLines = 10 const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : "")) const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : ""))
@@ -2351,9 +2376,15 @@ function Shell(props: ToolProps) {
if (expanded() || !collapsed().overflow) return content() if (expanded() || !collapsed().overflow) return content()
return collapsed().output return collapsed().output
}) })
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const toggle = () => {
const next = !expanded()
setExpanded(next)
if (next) void loadBackgroundOutput()
}
return ( return (
<BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}> <BlockTool part={props.part} onClick={expandable() ? toggle : undefined}>
<box gap={1}> <box gap={1}>
<Show <Show
when={command()} when={command()}
@@ -2383,7 +2414,7 @@ function Shell(props: ToolProps) {
<Show when={shellID()}> <Show when={shellID()}>
<StatusBadge>Background</StatusBadge> <StatusBadge>Background</StatusBadge>
</Show> </Show>
<Show when={collapsed().overflow}> <Show when={expandable()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text> <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show> </Show>
</box> </box>