From 387bff8fd99c1320ccf86bb84dd7c87251039a60 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 14 Jul 2026 21:29:02 -0400 Subject: [PATCH] feat(tui): stream shell tool output --- packages/core/src/tool/shell.ts | 43 +++++++++++++++++------ packages/core/test/tool-shell.test.ts | 34 +++++++++++++++++- packages/tui/src/routes/session/index.tsx | 39 +++++++++++++++++--- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 5109e2a535..c1dc95c904 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -3,7 +3,7 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" 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 { LocationMutation } from "../location-mutation" 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: Restore PowerShell and cmd-specific invocation/path handling on Windows. // 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: 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. @@ -201,9 +200,18 @@ export const Plugin = { 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 final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) if (final.status === "timeout") { return { @@ -215,13 +223,11 @@ export const Plugin = { } } - const truncated = page.size > page.cursor - const body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + const capture = yield* captureShell() return { exit: final.exit, - output: `${body}${notice}`, - truncated, + output: capture.output, + truncated: capture.truncated, status: "completed" as const, } }) @@ -250,9 +256,24 @@ export const Plugin = { } } - const result = yield* runtime.job - .block({ id: job.id, sessionID: context.sessionID }) - .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) + const progress = yield* Effect.sleep("1 second").pipe( + Effect.andThen( + 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") { yield* shell.timeout(info.id, 0) yield* notifyWhenDone(context.sessionID, context.callID, input.command) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 896cc96d03..007099b000 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -166,6 +166,10 @@ const overflowCommand = (bytes: number) => isWindows ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` : `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 = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => 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", () => Effect.acquireUseRelease( 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.", "Restore PowerShell and cmd-specific invocation/path handling on Windows.", "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.", "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.", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6e865269ca..69e5644016 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2322,6 +2322,7 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() const ctx = use() + const client = useClient() const data = useData() const permission = createMemo(() => { 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 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(() => { if (props.part.state.status === "streaming") return "" - if (shellID()) return "" + if (shellID()) return expanded() ? backgroundOutput() : "" const content = props.part.state.content[0] return stripAnsi(content?.type === "text" ? content.text.trim() : "") }) - const [expanded, setExpanded] = createSignal(false) const maxLines = 10 const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : "")) @@ -2351,9 +2376,15 @@ function Shell(props: ToolProps) { if (expanded() || !collapsed().overflow) return content() return collapsed().output }) + const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow) + const toggle = () => { + const next = !expanded() + setExpanded(next) + if (next) void loadBackgroundOutput() + } return ( - setExpanded((prev) => !prev) : undefined}> + Background - + {expanded() ? "Click to collapse" : "Click to expand"}