feat(tui): stream shell tool output
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user