Kaynağa Gözat

feat(tui): stream shell tool output

Dax Raad 1 ay önce
ebeveyn
işleme
387bff8fd9

+ 32 - 11
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)

+ 33 - 1
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 = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
   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.",

+ 35 - 4
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 (
-    <BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}>
+    <BlockTool part={props.part} onClick={expandable() ? toggle : undefined}>
       <box gap={1}>
         <Show
           when={command()}
@@ -2383,7 +2414,7 @@ function Shell(props: ToolProps) {
         <Show when={shellID()}>
           <StatusBadge>Background</StatusBadge>
         </Show>
-        <Show when={collapsed().overflow}>
+        <Show when={expandable()}>
           <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
         </Show>
       </box>