Ver código fonte

cli: add live subagent footer inspector to run

Keep direct-mode subagent activity in the footer so child sessions can be
inspected.
Simon Klee 17 horas atrás
pai
commit
dd0dca2951

+ 127 - 1
packages/opencode/src/cli/cmd/run/demo.ts

@@ -17,7 +17,15 @@ import path from "path"
 import type { Event } from "@opencode-ai/sdk/v2"
 import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
 import { writeSessionOutput } from "./stream"
-import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo, RunPrompt } from "./types"
+import type {
+  FooterApi,
+  PermissionReply,
+  QuestionReject,
+  QuestionReply,
+  RunDemo,
+  RunPrompt,
+  StreamCommit,
+} from "./types"
 
 const KINDS = ["text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"]
 const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
@@ -115,6 +123,60 @@ function note(footer: FooterApi, text: string): void {
   })
 }
 
+function clearSubagent(footer: FooterApi): void {
+  footer.event({
+    type: "stream.subagent",
+    state: {
+      tabs: [],
+      details: {},
+      permissions: [],
+      questions: [],
+    },
+  })
+}
+
+function showSubagent(
+  state: State,
+  input: {
+    sessionID: string
+    partID: string
+    callID: string
+    label: string
+    description: string
+    status: "running" | "completed" | "error"
+    title?: string
+    toolCalls?: number
+    commits: StreamCommit[]
+  },
+) {
+  state.footer.event({
+    type: "stream.subagent",
+    state: {
+      tabs: [
+        {
+          sessionID: input.sessionID,
+          partID: input.partID,
+          callID: input.callID,
+          label: input.label,
+          description: input.description,
+          status: input.status,
+          title: input.title,
+          toolCalls: input.toolCalls,
+          lastUpdatedAt: Date.now(),
+        },
+      ],
+      details: {
+        [input.sessionID]: {
+          sessionID: input.sessionID,
+          commits: input.commits,
+        },
+      },
+      permissions: [],
+      questions: [],
+    },
+  })
+}
+
 function wait(ms: number, signal?: AbortSignal): Promise<void> {
   return new Promise((resolve) => {
     if (!signal) {
@@ -564,6 +626,68 @@ function emitTask(state: State): void {
       sessionId: "sub_demo_1",
     },
   })
+  showSubagent(state, {
+    sessionID: "sub_demo_1",
+    partID: ref.part,
+    callID: ref.call,
+    label: "Explore",
+    description: "Scan run/* for reducer touchpoints",
+    status: "completed",
+    title: "Reducer touchpoints found",
+    toolCalls: 4,
+    commits: [
+      {
+        kind: "user",
+        text: "Scan run/* for reducer touchpoints",
+        phase: "start",
+        source: "system",
+      },
+      {
+        kind: "reasoning",
+        text: "Thinking: tracing reducer and footer boundaries",
+        phase: "progress",
+        source: "reasoning",
+        messageID: "sub_demo_msg_reasoning",
+        partID: "sub_demo_reasoning_1",
+      },
+      {
+        kind: "tool",
+        text: "running read",
+        phase: "start",
+        source: "tool",
+        messageID: "sub_demo_msg_tool",
+        partID: "sub_demo_tool_1",
+        tool: "read",
+        part: {
+          id: "sub_demo_tool_1",
+          type: "tool",
+          sessionID: "sub_demo_1",
+          messageID: "sub_demo_msg_tool",
+          callID: "sub_demo_call_1",
+          tool: "read",
+          state: {
+            status: "running",
+            input: {
+              filePath: "packages/opencode/src/cli/cmd/run/stream.ts",
+              offset: 1,
+              limit: 200,
+            },
+            time: {
+              start: Date.now(),
+            },
+          },
+        } as never,
+      },
+      {
+        kind: "assistant",
+        text: "Footer updates flow through stream.ts into RunFooter",
+        phase: "progress",
+        source: "assistant",
+        messageID: "sub_demo_msg_text",
+        partID: "sub_demo_text_1",
+      },
+    ],
+  })
 }
 
 function emitTodo(state: State): void {
@@ -980,6 +1104,8 @@ export function createRunDemo(input: Input) {
     const list = text.split(/\s+/)
     const cmd = list[0] || ""
 
+    clearSubagent(state.footer)
+
     if (cmd === "/help") {
       intro(state)
       return true

+ 194 - 0
packages/opencode/src/cli/cmd/run/footer.subagent.tsx

@@ -0,0 +1,194 @@
+/** @jsxImportSource @opentui/solid */
+import type { ScrollBoxRenderable } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import "opentui-spinner/solid"
+import { For, createMemo, createSignal } from "solid-js"
+import { SPINNER_FRAMES } from "../tui/component/spinner"
+import { RunEntryContent, sameEntryGroup } from "./scrollback.writer"
+import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
+import type { RunFooterTheme, RunTheme } from "./theme"
+
+export const SUBAGENT_TAB_ROWS = 2
+export const SUBAGENT_INSPECTOR_ROWS = 8
+
+function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
+  if (status === "completed") {
+    return theme.success
+  }
+
+  if (status === "error") {
+    return theme.error
+  }
+
+  return theme.highlight
+}
+
+function statusIcon(status: FooterSubagentTab["status"]) {
+  if (status === "completed") {
+    return "●"
+  }
+
+  if (status === "error") {
+    return "◍"
+  }
+
+  return "◔"
+}
+
+export function RunFooterSubagentTabs(props: {
+  tabs: FooterSubagentTab[]
+  selected?: string
+  theme: RunFooterTheme
+  onToggle: (sessionID: string) => void
+}) {
+  const [hover, setHover] = createSignal<string>()
+
+  return (
+    <box
+      id="run-direct-footer-subagent-tabs"
+      width="100%"
+      height={SUBAGENT_TAB_ROWS}
+      paddingLeft={2}
+      paddingRight={2}
+      paddingBottom={1}
+      flexDirection="row"
+      flexShrink={0}
+    >
+      <box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>
+        {props.tabs.map((tab) => {
+          const active = () => props.selected === tab.sessionID
+          const hovered = () => hover() === tab.sessionID
+          const emphasized = () => active() || hovered()
+          return (
+            <box
+              paddingRight={1}
+              onMouseOver={() => {
+                setHover(tab.sessionID)
+              }}
+              onMouseOut={() => {
+                if (hover() === tab.sessionID) {
+                  setHover(undefined)
+                }
+              }}
+              onMouseUp={() => {
+                props.onToggle(tab.sessionID)
+              }}
+            >
+              <box flexDirection="row" gap={1} width="100%">
+                {tab.status === "running" ? (
+                  <box flexShrink={0}>
+                    <spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
+                  </box>
+                ) : (
+                  <text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
+                    {statusIcon(tab.status)}
+                  </text>
+                )}
+                <text fg={emphasized() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
+                  {tab.label}
+                </text>
+              </box>
+            </box>
+          )
+        })}
+      </box>
+    </box>
+  )
+}
+
+export function RunFooterSubagentBody(props: {
+  active: () => boolean
+  theme: () => RunTheme
+  detail: () => FooterSubagentDetail | undefined
+  width: () => number
+  diffStyle?: RunDiffStyle
+  onCycle: (dir: -1 | 1) => void
+  onClose: () => void
+}) {
+  const commits = createMemo(() => props.detail()?.commits ?? [])
+  const entries = createMemo(() => {
+    return commits().map((commit, index, list) => ({
+      commit,
+      gap: index > 0 && !sameEntryGroup(list[index - 1], commit),
+    }))
+  })
+  let scroll: ScrollBoxRenderable | undefined
+
+  useKeyboard((event) => {
+    if (!props.active()) {
+      return
+    }
+
+    if (event.name === "escape") {
+      event.preventDefault()
+      props.onClose()
+      return
+    }
+
+    if (event.name === "tab") {
+      event.preventDefault()
+      props.onCycle(event.shift ? -1 : 1)
+      return
+    }
+
+    if (event.name === "up" || event.name === "k") {
+      event.preventDefault()
+      scroll?.scrollBy(-1)
+      return
+    }
+
+    if (event.name === "down" || event.name === "j") {
+      event.preventDefault()
+      scroll?.scrollBy(1)
+    }
+  })
+
+  return (
+    <box
+      id="run-direct-footer-subagent"
+      width="100%"
+      height="100%"
+      flexDirection="column"
+      backgroundColor={props.theme().footer.surface}
+    >
+      <box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
+        <scrollbox
+          width="100%"
+          height="100%"
+          stickyScroll={true}
+          stickyStart="bottom"
+          verticalScrollbarOptions={{
+            trackOptions: {
+              backgroundColor: props.theme().footer.surface,
+              foregroundColor: props.theme().footer.line,
+            },
+          }}
+          ref={(item) => {
+            scroll = item as ScrollBoxRenderable
+          }}
+        >
+          <box width="100%" flexDirection="column" gap={0}>
+            <For each={entries()}>
+              {(item) => (
+                <box flexDirection="column" gap={0}>
+                  {item.gap ? <box height={1} flexShrink={0} /> : null}
+                  <RunEntryContent
+                    commit={item.commit}
+                    theme={props.theme()}
+                    opts={{ diffStyle: props.diffStyle }}
+                    width={props.width()}
+                  />
+                </box>
+              )}
+            </For>
+            {entries().length === 0 ? (
+              <text fg={props.theme().footer.muted} wrapMode="word">
+                No subagent activity yet
+              </text>
+            ) : null}
+          </box>
+        </scrollbox>
+      </box>
+    </box>
+  )
+}

+ 51 - 43
packages/opencode/src/cli/cmd/run/footer.ts

@@ -26,13 +26,13 @@
 import { CliRenderEvents, type CliRenderer } from "@opentui/core"
 import { render } from "@opentui/solid"
 import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
+import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
 import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
 import { printableBinding } from "./prompt.shared"
 import { RunFooterView } from "./footer.view"
 import { normalizeEntry } from "./scrollback.format"
 import { entryWriter } from "./scrollback"
-import { spacerWriter } from "./scrollback.writer"
-import { toolView } from "./tool"
+import { sameEntryGroup, spacerWriter } from "./scrollback.writer"
 import type { RunTheme } from "./theme"
 import type {
   RunAgent,
@@ -40,9 +40,11 @@ import type {
   FooterEvent,
   FooterKeybinds,
   FooterPatch,
+  FooterPromptRoute,
   RunPrompt,
   RunResource,
   FooterState,
+  FooterSubagentState,
   FooterView,
   PermissionReply,
   QuestionReject,
@@ -74,11 +76,21 @@ type RunFooterOptions = {
   onCycleVariant?: () => CycleResult | void
   onInterrupt?: () => void
   onExit?: () => void
+  onSubagentSelect?: (sessionID: string | undefined) => void
 }
 
 const PERMISSION_ROWS = 12
 const QUESTION_ROWS = 14
 
+function createEmptySubagentState(): FooterSubagentState {
+  return {
+    tabs: [],
+    details: {},
+    permissions: [],
+    questions: [],
+  }
+}
+
 export class RunFooter implements FooterApi {
   private closed = false
   private destroyed = false
@@ -98,6 +110,10 @@ export class RunFooter implements FooterApi {
   private setState: Setter<FooterState>
   private view: Accessor<FooterView>
   private setView: Setter<FooterView>
+  private subagent: Accessor<FooterSubagentState>
+  private setSubagent: Setter<FooterSubagentState>
+  private promptRoute: FooterPromptRoute = { type: "composer" }
+  private tabsVisible = false
   private interruptTimeout: NodeJS.Timeout | undefined
   private exitTimeout: NodeJS.Timeout | undefined
   private interruptHint: string
@@ -122,6 +138,9 @@ export class RunFooter implements FooterApi {
     const [view, setView] = createSignal<FooterView>({ type: "prompt" })
     this.view = view
     this.setView = setView
+    const [subagent, setSubagent] = createSignal<FooterSubagentState>(createEmptySubagentState())
+    this.subagent = subagent
+    this.setSubagent = setSubagent
     this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
     this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
 
@@ -133,11 +152,11 @@ export class RunFooter implements FooterApi {
           directory: options.directory,
           state: this.state,
           view: this.view,
+          subagent: this.subagent,
           findFiles: options.findFiles,
           agents: () => options.agents,
           resources: () => options.resources,
-          theme: options.theme.footer,
-          block: options.theme.block,
+          theme: options.theme,
           diffStyle: options.diffStyle,
           keybinds: options.keybinds,
           history: options.history,
@@ -151,7 +170,9 @@ export class RunFooter implements FooterApi {
           onExitRequest: this.handleExit,
           onExit: () => this.close(),
           onRows: this.syncRows,
+          onLayout: this.syncLayout,
           onStatus: this.setStatus,
+          onSubagentSelect: options.onSubagentSelect,
         }),
       this.renderer as unknown as Parameters<typeof render>[1],
     ).catch(() => {
@@ -241,6 +262,16 @@ export class RunFooter implements FooterApi {
       return
     }
 
+    if (next.type === "stream.subagent") {
+      if (this.destroyed || this.renderer.isDestroyed) {
+        return
+      }
+
+      this.setSubagent(next.state)
+      this.applyHeight()
+      return
+    }
+
     this.present(next.view)
   }
 
@@ -382,12 +413,18 @@ export class RunFooter implements FooterApi {
   // get fixed extra rows; the prompt view scales with textarea line count.
   private applyHeight(): void {
     const type = this.view().type
+    const tabs = this.tabsVisible ? SUBAGENT_TAB_ROWS : 0
     const height =
       type === "permission"
         ? this.base + PERMISSION_ROWS
         : type === "question"
           ? this.base + QUESTION_ROWS
-          : Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + PROMPT_MAX_ROWS, this.base + this.rows))
+          : this.promptRoute.type === "subagent"
+            ? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
+            : Math.max(
+                this.base + TEXTAREA_MIN_ROWS,
+                Math.min(this.base + tabs + PROMPT_MAX_ROWS, this.base + tabs + this.rows),
+              )
 
     if (height !== this.renderer.footerHeight) {
       this.renderer.footerHeight = height
@@ -410,6 +447,14 @@ export class RunFooter implements FooterApi {
     }
   }
 
+  private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean }): void => {
+    this.promptRoute = next.route
+    this.tabsVisible = next.tabs
+    if (this.view().type === "prompt") {
+      this.applyHeight()
+    }
+  }
+
   private handlePrompt = (input: RunPrompt): boolean => {
     if (this.isClosed) {
       return false
@@ -586,7 +631,7 @@ export class RunFooter implements FooterApi {
     }
 
     for (const item of this.queue.splice(0)) {
-      const same = sameGroup(this.tail, item)
+      const same = sameEntryGroup(this.tail, item)
       if (this.wrote && !same) {
         this.renderer.writeToScrollback(spacerWriter())
       }
@@ -597,40 +642,3 @@ export class RunFooter implements FooterApi {
     }
   }
 }
-
-function snap(commit: StreamCommit): boolean {
-  const tool = commit.tool ?? commit.part?.tool
-  return (
-    commit.kind === "tool" &&
-    commit.phase === "final" &&
-    (commit.toolState ?? commit.part?.state.status) === "completed" &&
-    typeof tool === "string" &&
-    Boolean(toolView(tool).snap)
-  )
-}
-
-function groupKey(commit: StreamCommit): string | undefined {
-  if (!commit.partID) {
-    return
-  }
-
-  if (snap(commit)) {
-    return `tool:${commit.partID}:final`
-  }
-
-  return `${commit.kind}:${commit.partID}`
-}
-
-function sameGroup(a: StreamCommit | undefined, b: StreamCommit): boolean {
-  if (!a) {
-    return false
-  }
-
-  const left = groupKey(a)
-  const right = groupKey(b)
-  if (left && right && left === right) {
-    return true
-  }
-
-  return a.kind === "tool" && a.phase === "start" && b.kind === "tool" && b.phase === "start"
-}

+ 323 - 174
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -11,26 +11,29 @@
 // The view itself is stateless except for derived memos.
 /** @jsxImportSource @opentui/solid */
 import { useTerminalDimensions } from "@opentui/solid"
-import { Match, Show, Switch, createMemo } from "solid-js"
+import { Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
 import "opentui-spinner/solid"
 import { createColors, createFrames } from "../tui/ui/spinner"
+import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
 import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
 import { RunPermissionBody } from "./footer.permission"
 import { RunQuestionBody } from "./footer.question"
 import { printableBinding } from "./prompt.shared"
 import type {
   FooterKeybinds,
+  FooterPromptRoute,
   RunAgent,
   RunPrompt,
   RunResource,
   FooterState,
+  FooterSubagentState,
   FooterView,
   PermissionReply,
   QuestionReject,
   QuestionReply,
   RunDiffStyle,
 } from "./types"
-import { RUN_THEME_FALLBACK, type RunBlockTheme, type RunFooterTheme } from "./theme"
+import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
 
 const EMPTY_BORDER = {
   topLeft: "",
@@ -53,8 +56,8 @@ type RunFooterViewProps = {
   resources: () => RunResource[]
   state: () => FooterState
   view?: () => FooterView
-  theme?: RunFooterTheme
-  block?: RunBlockTheme
+  subagent?: () => FooterSubagentState
+  theme?: RunTheme
   diffStyle?: RunDiffStyle
   keybinds: FooterKeybinds
   history?: RunPrompt[]
@@ -68,7 +71,9 @@ type RunFooterViewProps = {
   onExitRequest?: () => boolean
   onExit: () => void
   onRows: (rows: number) => void
+  onLayout: (input: { route: FooterPromptRoute; tabs: boolean }) => void
   onStatus: (text: string) => void
+  onSubagentSelect?: (sessionID: string | undefined) => void
 }
 
 export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
@@ -76,7 +81,33 @@ export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
 export function RunFooterView(props: RunFooterViewProps) {
   const term = useTerminalDimensions()
   const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
-  const prompt = createMemo(() => active().type === "prompt")
+  const subagent = createMemo<FooterSubagentState>(() => {
+    return (
+      props.subagent?.() ?? {
+        tabs: [],
+        details: {},
+        permissions: [],
+        questions: [],
+      }
+    )
+  })
+  const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
+  const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
+  const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
+  const selected = createMemo(() => {
+    const current = route()
+    return current.type === "subagent" ? current.sessionID : undefined
+  })
+  const tabs = createMemo(() => subagent().tabs)
+  const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0)
+  const detail = createMemo(() => {
+    const current = route()
+    if (current.type !== "subagent") {
+      return
+    }
+
+    return subagent().details[current.sessionID]
+  })
   const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
   const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
   const hints = createMemo(() => hintFlags(term().width))
@@ -87,8 +118,9 @@ export function RunFooterView(props: RunFooterViewProps) {
   const duration = createMemo(() => props.state().duration)
   const usage = createMemo(() => props.state().usage)
   const interruptKey = createMemo(() => interrupt() || "/exit")
-  const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK.footer)
-  const block = createMemo(() => props.block ?? RUN_THEME_FALLBACK.block)
+  const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
+  const theme = createMemo(() => runTheme().footer)
+  const block = createMemo(() => runTheme().block)
   const spin = createMemo(() => {
     return {
       frames: createFrames({
@@ -113,6 +145,50 @@ export function RunFooterView(props: RunFooterViewProps) {
     const view = active()
     return view.type === "question" ? view : undefined
   })
+  const promptView = createMemo(() => {
+    if (active().type !== "prompt") {
+      return active().type
+    }
+
+    return route().type === "composer" ? "prompt" : "subagent"
+  })
+
+  const openTab = (sessionID: string) => {
+    setRoute({ type: "subagent", sessionID })
+    props.onSubagentSelect?.(sessionID)
+  }
+
+  const closeTab = () => {
+    setRoute({ type: "composer" })
+    props.onSubagentSelect?.(undefined)
+  }
+
+  const toggleTab = (sessionID: string) => {
+    const current = route()
+    if (current.type === "subagent" && current.sessionID === sessionID) {
+      closeTab()
+      return
+    }
+
+    openTab(sessionID)
+  }
+
+  const cycleTab = (dir: -1 | 1) => {
+    if (tabs().length === 0) {
+      return
+    }
+
+    const routeState = route()
+    const current =
+      routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1
+    const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length
+    const next = tabs()[index]
+    if (!next) {
+      return
+    }
+
+    openTab(next.sessionID)
+  }
   const composer = createPromptState({
     directory: props.directory,
     findFiles: props.findFiles,
@@ -120,7 +196,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     resources: props.resources,
     keybinds: props.keybinds,
     state: props.state,
-    view: () => active().type,
+    view: promptView,
     prompt,
     width: () => term().width,
     theme,
@@ -133,7 +209,27 @@ export function RunFooterView(props: RunFooterViewProps) {
     onRows: props.onRows,
     onStatus: props.onStatus,
   })
-  const menu = createMemo(() => active().type === "prompt" && composer.visible())
+  const menu = createMemo(() => prompt() && composer.visible())
+
+  createEffect(() => {
+    const current = route()
+    if (current.type === "composer") {
+      return
+    }
+
+    if (tabs().some((item) => item.sessionID === current.sessionID)) {
+      return
+    }
+
+    closeTab()
+  })
+
+  createEffect(() => {
+    props.onLayout({
+      route: route(),
+      tabs: tabs().length > 0,
+    })
+  })
 
   return (
     <box
@@ -148,187 +244,240 @@ export function RunFooterView(props: RunFooterViewProps) {
     >
       <box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
 
-      <box
-        id="run-direct-footer-composer-frame"
-        width="100%"
-        flexShrink={0}
-        border={["left"]}
-        borderColor={theme().highlight}
-        customBorderChars={{
-          ...EMPTY_BORDER,
-          vertical: "┃",
-          bottomLeft: "╹",
-        }}
-      >
-        <box
-          id="run-direct-footer-composer-area"
-          width="100%"
-          flexGrow={1}
-          paddingLeft={0}
-          paddingRight={0}
-          paddingTop={0}
-          flexDirection="column"
-          backgroundColor={theme().surface}
-          gap={0}
-        >
-          <box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
-            <Switch>
-              <Match when={active().type === "prompt"}>
-                <RunPromptBody
-                  theme={theme}
-                  placeholder={composer.placeholder}
-                  bindings={composer.bindings}
-                  onSubmit={composer.onSubmit}
-                  onKeyDown={composer.onKeyDown}
-                  onContentChange={composer.onContentChange}
-                  bind={composer.bind}
-                />
-              </Match>
-              <Match when={active().type === "permission"}>
-                <RunPermissionBody
-                  request={permission()!.request}
-                  theme={theme()}
-                  block={block()}
-                  diffStyle={props.diffStyle}
-                  onReply={props.onPermissionReply}
-                />
-              </Match>
-              <Match when={active().type === "question"}>
-                <RunQuestionBody
-                  request={question()!.request}
-                  theme={theme()}
-                  onReply={props.onQuestionReply}
-                  onReject={props.onQuestionReject}
-                />
-              </Match>
-            </Switch>
-          </box>
-
-          <box
-            id="run-direct-footer-meta-row"
-            width="100%"
-            flexDirection="row"
-            gap={1}
-            paddingLeft={2}
-            flexShrink={0}
-            paddingTop={1}
-          >
-            <text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
-              {props.agent}
-            </text>
-            <text id="run-direct-footer-model" fg={theme().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
-              {props.state().model}
-            </text>
-          </box>
-        </box>
-      </box>
-
-      <box
-        id="run-direct-footer-line-6"
-        width="100%"
-        height={1}
-        border={["left"]}
-        borderColor={theme().highlight}
-        backgroundColor="transparent"
-        customBorderChars={{
-          ...EMPTY_BORDER,
-          vertical: "╹",
-        }}
-        flexShrink={0}
-      >
-        <box
-          id="run-direct-footer-line-6-fill"
-          width="100%"
-          height={1}
-          border={["bottom"]}
-          borderColor={theme().surface}
-          backgroundColor={menu() ? theme().shade : "transparent"}
-          customBorderChars={{
-            ...EMPTY_BORDER,
-            horizontal: "▀",
-          }}
-        />
-      </box>
+      <Show when={showTabs()}>
+        <RunFooterSubagentTabs tabs={tabs()} selected={selected()} theme={theme()} onToggle={toggleTab} />
+      </Show>
 
       <Show
-        when={menu()}
+        when={inspecting()}
         fallback={
-          <box
-            id="run-direct-footer-row"
-            width="100%"
-            height={1}
-            flexDirection="row"
-            justifyContent="space-between"
-            gap={1}
-            flexShrink={0}
-          >
-            <Show when={busy() || exiting()}>
-              <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
-                <Show when={exiting()}>
-                  <text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
-                    Press Ctrl-c again to exit
-                  </text>
-                </Show>
-
-                <Show when={busy() && !exiting()}>
-                  <box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
-                    <spinner color={spin().color} frames={spin().frames} interval={40} />
-                  </box>
+          <box width="100%" flexDirection="column" gap={0}>
+            <box
+              id="run-direct-footer-composer-frame"
+              width="100%"
+              flexShrink={0}
+              border={["left"]}
+              borderColor={theme().highlight}
+              customBorderChars={{
+                ...EMPTY_BORDER,
+                vertical: "┃",
+                bottomLeft: "╹",
+              }}
+            >
+              <box
+                id="run-direct-footer-composer-area"
+                width="100%"
+                flexGrow={1}
+                paddingLeft={0}
+                paddingRight={0}
+                paddingTop={0}
+                flexDirection="column"
+                backgroundColor={theme().surface}
+                gap={0}
+              >
+                <box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
+                  <Switch>
+                    <Match when={active().type === "prompt" && route().type === "composer"}>
+                      <RunPromptBody
+                        theme={theme}
+                        placeholder={composer.placeholder}
+                        bindings={composer.bindings}
+                        onSubmit={composer.onSubmit}
+                        onKeyDown={composer.onKeyDown}
+                        onContentChange={composer.onContentChange}
+                        bind={composer.bind}
+                      />
+                    </Match>
+                    <Match when={active().type === "permission"}>
+                      <RunPermissionBody
+                        request={permission()!.request}
+                        theme={theme()}
+                        block={block()}
+                        diffStyle={props.diffStyle}
+                        onReply={props.onPermissionReply}
+                      />
+                    </Match>
+                    <Match when={active().type === "question"}>
+                      <RunQuestionBody
+                        request={question()!.request}
+                        theme={theme()}
+                        onReply={props.onQuestionReply}
+                        onReject={props.onQuestionReject}
+                      />
+                    </Match>
+                  </Switch>
+                </box>
 
+                <box
+                  id="run-direct-footer-meta-row"
+                  width="100%"
+                  flexDirection="row"
+                  gap={1}
+                  paddingLeft={2}
+                  flexShrink={0}
+                  paddingTop={1}
+                >
+                  <text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
+                    {props.agent}
+                  </text>
                   <text
-                    id="run-direct-footer-hint-interrupt"
-                    fg={armed() ? theme().highlight : theme().text}
+                    id="run-direct-footer-model"
+                    fg={theme().text}
                     wrapMode="none"
                     truncate
+                    flexGrow={1}
+                    flexShrink={1}
                   >
-                    {interruptKey()}{" "}
-                    <span style={{ fg: armed() ? theme().highlight : theme().muted }}>
-                      {armed() ? "again to interrupt" : "interrupt"}
-                    </span>
-                  </text>
-                </Show>
-              </box>
-            </Show>
-
-            <Show when={!busy() && !exiting() && duration().length > 0}>
-              <box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
-                <text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
-                  ▣
-                </text>
-                <box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
-                  <text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
-                    ·
-                  </text>
-                  <text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
-                    {duration()}
+                    {props.state().model}
                   </text>
                 </box>
               </box>
-            </Show>
-
-            <box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
+            </box>
 
-            <box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
-              <Show when={queue() > 0}>
-                <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
-                  {queue()} queued
-                </text>
-              </Show>
-              <Show when={usage().length > 0}>
-                <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
-                  {usage()}
-                </text>
-              </Show>
-              <Show when={variant().length > 0 && hints().variant}>
-                <text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
-                  {variant()} variant
-                </text>
-              </Show>
+            <box
+              id="run-direct-footer-line-6"
+              width="100%"
+              height={1}
+              border={["left"]}
+              borderColor={theme().highlight}
+              backgroundColor="transparent"
+              customBorderChars={{
+                ...EMPTY_BORDER,
+                vertical: "╹",
+              }}
+              flexShrink={0}
+            >
+              <box
+                id="run-direct-footer-line-6-fill"
+                width="100%"
+                height={1}
+                border={["bottom"]}
+                borderColor={theme().surface}
+                backgroundColor={menu() ? theme().shade : "transparent"}
+                customBorderChars={{
+                  ...EMPTY_BORDER,
+                  horizontal: "▀",
+                }}
+              />
             </box>
+
+            <Show
+              when={menu()}
+              fallback={
+                <box
+                  id="run-direct-footer-row"
+                  width="100%"
+                  height={1}
+                  flexDirection="row"
+                  justifyContent="space-between"
+                  gap={1}
+                  flexShrink={0}
+                >
+                  <Show when={busy() || exiting()}>
+                    <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
+                      <Show when={exiting()}>
+                        <text
+                          id="run-direct-footer-hint-exit"
+                          fg={theme().highlight}
+                          wrapMode="none"
+                          truncate
+                          marginLeft={1}
+                        >
+                          Press Ctrl-c again to exit
+                        </text>
+                      </Show>
+
+                      <Show when={busy() && !exiting()}>
+                        <box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
+                          <spinner color={spin().color} frames={spin().frames} interval={40} />
+                        </box>
+
+                        <text
+                          id="run-direct-footer-hint-interrupt"
+                          fg={armed() ? theme().highlight : theme().text}
+                          wrapMode="none"
+                          truncate
+                        >
+                          {interruptKey()}{" "}
+                          <span style={{ fg: armed() ? theme().highlight : theme().muted }}>
+                            {armed() ? "again to interrupt" : "interrupt"}
+                          </span>
+                        </text>
+                      </Show>
+                    </box>
+                  </Show>
+
+                  <Show when={!busy() && !exiting() && duration().length > 0}>
+                    <box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
+                      <text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
+                        ▣
+                      </text>
+                      <box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
+                        <text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
+                          ·
+                        </text>
+                        <text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
+                          {duration()}
+                        </text>
+                      </box>
+                    </box>
+                  </Show>
+
+                  <box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
+
+                  <box
+                    id="run-direct-footer-hint-group"
+                    flexDirection="row"
+                    gap={2}
+                    flexShrink={0}
+                    justifyContent="flex-end"
+                  >
+                    <Show when={queue() > 0}>
+                      <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
+                        {queue()} queued
+                      </text>
+                    </Show>
+                    <Show when={usage().length > 0}>
+                      <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
+                        {usage()}
+                      </text>
+                    </Show>
+                    <Show when={variant().length > 0 && hints().variant}>
+                      <text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
+                        {variant()} variant
+                      </text>
+                    </Show>
+                  </box>
+                </box>
+              }
+            >
+              <RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
+            </Show>
           </box>
         }
       >
-        <RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
+        <box
+          id="run-direct-footer-subagent-frame"
+          width="100%"
+          flexGrow={1}
+          flexShrink={1}
+          border={["left"]}
+          borderColor={theme().highlight}
+          customBorderChars={{
+            ...EMPTY_BORDER,
+            vertical: "┃",
+          }}
+        >
+          <RunFooterSubagentBody
+            active={inspecting}
+            theme={runTheme}
+            detail={detail}
+            width={() => term().width}
+            diffStyle={props.diffStyle}
+            onCycle={cycleTab}
+            onClose={closeTab}
+          />
+        </box>
       </Show>
     </box>
   )

+ 3 - 1
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -63,6 +63,7 @@ export type LifecycleInput = {
   onQuestionReject: (input: QuestionReject) => void | Promise<void>
   onCycleVariant?: () => CycleResult | void
   onInterrupt?: () => void
+  onSubagentSelect?: (sessionID: string | undefined) => void
 }
 
 export type Lifecycle = {
@@ -153,7 +154,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
   const renderer = await createCliRenderer({
     targetFps: 30,
     maxFps: 60,
-    useMouse: false,
+    useMouse: true,
     autoFocus: false,
     openConsoleOnError: false,
     exitOnCtrlC: false,
@@ -211,6 +212,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     onQuestionReject: input.onQuestionReject,
     onCycleVariant: input.onCycleVariant,
     onInterrupt: input.onInterrupt,
+    onSubagentSelect: input.onSubagentSelect,
   })
 
   const sigint = () => {

+ 8 - 0
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -90,6 +90,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
   ])
   shown = !session.first
   let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
+  let selectSubagent: ((sessionID: string | undefined) => void) | undefined
 
   const shell = await createRuntimeLifecycle({
     directory: ctx.directory,
@@ -160,6 +161,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
           aborting = false
         })
     },
+    onSubagentSelect: (sessionID) => {
+      selectSubagent?.(sessionID)
+      log?.write("subagent.select", {
+        sessionID,
+      })
+    },
   })
   const footer = shell.footer
 
@@ -209,6 +216,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
       footer,
       trace: log,
     })
+    selectSubagent = stream.selectSubagent
 
     try {
       if (demo) {

+ 158 - 122
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

@@ -25,7 +25,7 @@ import {
 import { createScrollbackWriter, type JSX } from "@opentui/solid"
 import { For, Show } from "solid-js"
 import * as Filesystem from "../../../util/filesystem"
-import { toolDiffView, toolFiletype, toolFrame, toolSnapshot } from "./tool"
+import { toolDiffView, toolFiletype, toolFrame, toolSnapshot, toolView } from "./tool"
 import { clean, normalizeEntry } from "./scrollback.format"
 import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme"
 import type { ScrollbackOptions, StreamCommit } from "./types"
@@ -426,23 +426,165 @@ function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) {
   )
 }
 
-function textWriter(body: string, commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
-  const style = look(commit, theme)
-  return (ctx) =>
-    fit(
-      createScrollbackWriter(() => <TextEntry body={body} fg={style.fg} attrs={style.attrs} />, {
-        width: cols(ctx),
-        startOnNewLine: flags.startOnNewLine,
-        trailingNewline: flags.trailingNewline,
-      })(ctx),
-      ctx,
+function snapCommit(commit: StreamCommit) {
+  const state = commit.toolState ?? commit.part?.state.status
+  return (
+    commit.kind === "tool" &&
+    commit.phase === "final" &&
+    state === "completed" &&
+    Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
+  )
+}
+
+export function entryGroupKey(commit: StreamCommit): string | undefined {
+  if (!commit.partID) {
+    return
+  }
+
+  if (snapCommit(commit)) {
+    return `tool:${commit.partID}:final`
+  }
+
+  return `${commit.kind}:${commit.partID}`
+}
+
+export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
+  if (!left) {
+    return false
+  }
+
+  const current = entryGroupKey(left)
+  const next = entryGroupKey(right)
+  if (current && next && current === next) {
+    return true
+  }
+
+  return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start"
+}
+
+export function RunEntryTextContent(props: { commit: StreamCommit; theme: RunEntryTheme }) {
+  const body = normalizeEntry(props.commit)
+  if (props.commit.kind === "reasoning") {
+    return <ReasoningEntry body={body} theme={props.theme} />
+  }
+
+  const style = look(props.commit, props.theme)
+  return <TextEntry body={body} fg={style.fg} attrs={style.attrs} />
+}
+
+export function RunEntrySnapContent(props: {
+  commit: StreamCommit
+  theme: RunTheme
+  opts?: ScrollbackOptions
+  width?: number
+}) {
+  const raw = clean(props.commit.text)
+  const snap = toolSnapshot(props.commit, raw)
+  if (!snap) {
+    return <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
+  }
+
+  const info = toolFrame(props.commit, raw)
+  if (snap.kind === "code") {
+    return (
+      <CodeTool
+        theme={props.theme}
+        data={{
+          title: snap.title,
+          content: snap.content,
+          filetype: toolFiletype(snap.file),
+          diagnostics: diagnostics(info.meta, snap.file ?? ""),
+        }}
+      />
+    )
+  }
+
+  if (snap.kind === "diff") {
+    const list = snap.items
+      .map((item) => {
+        if (!item.diff.trim()) {
+          return
+        }
+
+        return {
+          title: item.title,
+          diff: item.diff,
+          filetype: toolFiletype(item.file),
+          deletions: item.deletions,
+          diagnostics: diagnostics(info.meta, item.file ?? ""),
+        }
+      })
+      .filter((item): item is NonNullable<typeof item> => Boolean(item))
+
+    if (list.length === 0) {
+      return <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
+    }
+
+    return (
+      <box flexDirection="column" gap={1}>
+        <For each={list}>
+          {(item) => (
+            <DiffTool theme={props.theme} data={item} view={toolDiffView(props.width ?? 80, props.opts?.diffStyle)} />
+          )}
+        </For>
+      </box>
     )
+  }
+
+  if (snap.kind === "task") {
+    return (
+      <TaskTool
+        theme={props.theme}
+        data={{
+          title: snap.title,
+          rows: snap.rows,
+          tail: snap.tail,
+        }}
+      />
+    )
+  }
+
+  if (snap.kind === "todo") {
+    return (
+      <TodoTool
+        theme={props.theme}
+        data={{
+          items: snap.items,
+          tail: snap.tail,
+        }}
+      />
+    )
+  }
+
+  return (
+    <QuestionTool
+      theme={props.theme}
+      data={{
+        items: snap.items,
+        tail: snap.tail,
+      }}
+    />
+  )
+}
+
+export function RunEntryContent(props: {
+  commit: StreamCommit
+  theme?: RunTheme
+  opts?: ScrollbackOptions
+  width?: number
+}) {
+  const theme = props.theme ?? RUN_THEME_FALLBACK
+  if (snapCommit(props.commit)) {
+    return <RunEntrySnapContent commit={props.commit} theme={theme} opts={props.opts} width={props.width} />
+  }
+
+  return <RunEntryTextContent commit={props.commit} theme={theme.entry} />
 }
 
-function reasoningWriter(body: string, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
+function textWriter(commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
   return (ctx) =>
     fit(
-      createScrollbackWriter(() => <ReasoningEntry body={body} theme={theme} />, {
+      createScrollbackWriter(() => <RunEntryTextContent commit={commit} theme={theme} />, {
         width: cols(ctx),
         startOnNewLine: flags.startOnNewLine,
         trailingNewline: flags.trailingNewline,
@@ -468,35 +610,6 @@ function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter {
     })
 }
 
-function codeWriter(data: CodeInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
-  return (ctx) => full(() => <CodeTool theme={theme} data={data} />, ctx, flags)
-}
-
-function diffWriter(list: DiffInput[], theme: RunTheme, flags: Flags, view: "unified" | "split"): ScrollbackWriter {
-  return (ctx) =>
-    full(
-      () => (
-        <box flexDirection="column" gap={1}>
-          <For each={list}>{(data) => <DiffTool theme={theme} data={data} view={view} />}</For>
-        </box>
-      ),
-      ctx,
-      flags,
-    )
-}
-
-function taskWriter(data: TaskInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
-  return (ctx) => full(() => <TaskTool theme={theme} data={data} />, ctx, flags)
-}
-
-function todoWriter(data: TodoInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
-  return (ctx) => full(() => <TodoTool theme={theme} data={data} />, ctx, flags)
-}
-
-function questionWriter(data: QuestionInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
-  return (ctx) => full(() => <QuestionTool theme={theme} data={data} />, ctx, flags)
-}
-
 function flags(commit: StreamCommit): Flags {
   if (commit.kind === "user") {
     return {
@@ -540,13 +653,7 @@ function flags(commit: StreamCommit): Flags {
 }
 
 export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter {
-  const body = normalizeEntry(commit)
-  const snap = flags(commit)
-  if (commit.kind === "reasoning") {
-    return reasoningWriter(body, theme, snap)
-  }
-
-  return textWriter(body, commit, theme, snap)
+  return textWriter(commit, theme, flags(commit))
 }
 
 export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter {
@@ -555,81 +662,10 @@ export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: Scr
     return textEntryWriter(commit, theme.entry)
   }
 
-  const info = toolFrame(commit, clean(commit.text))
   const style = flags(commit)
 
-  if (snap.kind === "code") {
-    return codeWriter(
-      {
-        title: snap.title,
-        content: snap.content,
-        filetype: toolFiletype(snap.file),
-        diagnostics: diagnostics(info.meta, snap.file ?? ""),
-      },
-      theme,
-      style,
-    )
-  }
-
-  if (snap.kind === "diff") {
-    if (snap.items.length === 0) {
-      return textEntryWriter(commit, theme.entry)
-    }
-
-    const list = snap.items
-      .map((item) => {
-        if (!item.diff.trim()) {
-          return
-        }
-
-        return {
-          title: item.title,
-          diff: item.diff,
-          filetype: toolFiletype(item.file),
-          deletions: item.deletions,
-          diagnostics: diagnostics(info.meta, item.file ?? ""),
-        }
-      })
-      .filter((item): item is NonNullable<typeof item> => Boolean(item))
-
-    if (list.length === 0) {
-      return textEntryWriter(commit, theme.entry)
-    }
-
-    return (ctx) => diffWriter(list, theme, style, toolDiffView(ctx.width, opts.diffStyle))(ctx)
-  }
-
-  if (snap.kind === "task") {
-    return taskWriter(
-      {
-        title: snap.title,
-        rows: snap.rows,
-        tail: snap.tail,
-      },
-      theme,
-      style,
-    )
-  }
-
-  if (snap.kind === "todo") {
-    return todoWriter(
-      {
-        items: snap.items,
-        tail: snap.tail,
-      },
-      theme,
-      style,
-    )
-  }
-
-  return questionWriter(
-    {
-      items: snap.items,
-      tail: snap.tail,
-    },
-    theme,
-    style,
-  )
+  return (ctx) =>
+    full(() => <RunEntrySnapContent commit={commit} theme={theme} opts={opts} width={cols(ctx)} />, ctx, style)
 }
 
 export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {

+ 52 - 11
packages/opencode/src/cli/cmd/run/session-data.ts

@@ -24,7 +24,7 @@
 //   `data.questions`. The footer shows whichever is first. When a reply
 //   event arrives, the queue entry is removed and the footer falls back
 //   to the next pending request or to the prompt view.
-import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
+import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
 import * as Locale from "../../../util/locale"
 import { toolView } from "./tool"
 import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
@@ -44,7 +44,7 @@ type Tokens = {
   }
 }
 
-type PartKind = "assistant" | "reasoning"
+type PartKind = "assistant" | "reasoning" | "user"
 type MessageRole = "assistant" | "user"
 type Dict = Record<string, unknown>
 type SessionCommit = StreamCommit
@@ -63,6 +63,7 @@ type SessionCommit = StreamCommit
 // - end:    part IDs whose time.end has arrived (part is finished)
 // - echo:   message ID → bash outputs to strip from the next assistant chunk
 export type SessionData = {
+  includeUserText: boolean
   announced: boolean
   ids: Set<string>
   tools: Set<string>
@@ -92,8 +93,13 @@ export type SessionDataOutput = {
   footer?: FooterOutput
 }
 
-export function createSessionData(): SessionData {
+export function createSessionData(
+  input: {
+    includeUserText?: boolean
+  } = {},
+): SessionData {
   return {
+    includeUserText: input.includeUserText ?? false,
     announced: false,
     ids: new Set(),
     tools: new Set(),
@@ -143,7 +149,7 @@ function formatUsage(
   return text
 }
 
-function formatError(error: {
+export function formatError(error: {
   name?: string
   message?: string
   data?: {
@@ -255,6 +261,33 @@ function remove<T extends { id: string }>(list: T[], id: string): boolean {
   return true
 }
 
+export function bootstrapSessionData(input: {
+  data: SessionData
+  messages: Array<{
+    parts: Part[]
+  }>
+  permissions: PermissionRequest[]
+  questions: QuestionRequest[]
+}) {
+  for (const message of input.messages) {
+    for (const part of message.parts) {
+      if (part.type !== "tool") {
+        continue
+      }
+
+      input.data.call.set(key(part.messageID, part.callID), part.state.input)
+    }
+  }
+
+  for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
+    upsert(input.data.permissions, enrichPermission(input.data, request))
+  }
+
+  for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
+    upsert(input.data.questions, request)
+  }
+}
+
 function key(msg: string, call: string): string {
   return `${msg}:${call}`
 }
@@ -360,7 +393,11 @@ function ready(data: SessionData, partID: string): boolean {
     return false
   }
 
-  return role === "assistant"
+  if (role === "assistant") {
+    return true
+  }
+
+  return data.includeUserText && role === "user"
 }
 
 function syncText(data: SessionData, partID: string, next: string) {
@@ -458,7 +495,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
       kind,
       text: chunk,
       phase: "progress",
-      source: kind,
+      source: kind === "user" ? "system" : kind,
       messageID: msg,
       partID,
     })
@@ -472,7 +509,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
     kind,
     text: "",
     phase: "final",
-    source: kind,
+    source: kind === "user" ? "system" : kind,
     messageID: msg,
     partID,
     interrupted: true,
@@ -496,7 +533,7 @@ function replay(data: SessionData, commits: SessionCommit[], messageID: string,
       continue
     }
 
-    if (role === "user") {
+    if (role === "user" && !data.includeUserText) {
       data.ids.add(partID)
       drop(data, partID)
       continue
@@ -507,6 +544,10 @@ function replay(data: SessionData, commits: SessionCommit[], messageID: string,
       continue
     }
 
+    if (role === "user" && kind === "assistant") {
+      data.part.set(partID, "user")
+    }
+
     if (kind === "reasoning" && !thinking) {
       if (data.end.has(partID)) {
         data.ids.add(partID)
@@ -577,7 +618,7 @@ export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
     }
 
     const msg = data.msg.get(partID)
-    if (msg && data.role.get(msg) === "user") {
+    if (msg && data.role.get(msg) === "user" && !data.includeUserText) {
       data.ids.add(partID)
       drop(data, partID)
       continue
@@ -785,7 +826,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
 
     const msg = part.messageID
     const role = msg ? data.role.get(msg) : undefined
-    if (role === "user") {
+    if (role === "user" && part.type === "text" && !data.includeUserText) {
       data.ids.add(part.id)
       drop(data, part.id)
       return out(data, commits)
@@ -799,7 +840,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
       return out(data, commits)
     }
 
-    data.part.set(part.id, kind)
+    data.part.set(part.id, role === "user" && kind === "assistant" ? "user" : kind)
     syncText(data, part.id, part.text)
 
     if (part.time?.end) {

+ 382 - 25
packages/opencode/src/cli/cmd/run/stream.transport.ts

@@ -13,9 +13,41 @@
 // We also re-check live session status before resolving an idle event so a
 // delayed idle from an older turn cannot complete a newer busy turn.
 import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
-import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
-import { writeSessionOutput } from "./stream"
-import type { FooterApi, RunFilePart, RunInput, RunPrompt, StreamCommit } from "./types"
+import {
+  bootstrapSessionData,
+  createSessionData,
+  flushInterrupted,
+  reduceSessionData,
+  type SessionData,
+} from "./session-data"
+import {
+  bootstrapSubagentCalls,
+  bootstrapSubagentData,
+  clearFinishedSubagents,
+  createSubagentData,
+  listSubagentPermissions,
+  listSubagentQuestions,
+  listSubagentTabs,
+  reduceSubagentData,
+  snapshotSelectedSubagentData,
+  snapshotSubagentData,
+  SUBAGENT_BOOTSTRAP_LIMIT,
+  SUBAGENT_CALL_BOOTSTRAP_LIMIT,
+  type SubagentData,
+} from "./subagent-data"
+import { traceFooterOutput, writeSessionOutput } from "./stream"
+import type {
+  FooterApi,
+  FooterOutput,
+  FooterPatch,
+  FooterSubagentState,
+  FooterSubagentTab,
+  FooterView,
+  RunFilePart,
+  RunInput,
+  RunPrompt,
+  StreamCommit,
+} from "./types"
 
 type Trace = {
   write(type: string, data?: unknown): void
@@ -52,6 +84,7 @@ export type SessionTurnInput = {
 
 export type SessionTransport = {
   runPromptTurn(input: SessionTurnInput): Promise<void>
+  selectSubagent(sessionID: string | undefined): void
   close(): Promise<void>
 }
 
@@ -162,6 +195,161 @@ export function formatUnknownError(error: unknown): string {
   return "unknown error"
 }
 
+function sameView(a: FooterView, b: FooterView) {
+  if (a.type !== b.type) {
+    return false
+  }
+
+  if (a.type === "prompt" && b.type === "prompt") {
+    return true
+  }
+
+  if (a.type === "prompt" || b.type === "prompt") {
+    return false
+  }
+
+  return a.request === b.request
+}
+
+function blockerStatus(view: FooterView) {
+  if (view.type === "permission") {
+    return "awaiting permission"
+  }
+
+  if (view.type === "question") {
+    return "awaiting answer"
+  }
+
+  return ""
+}
+
+function blockerOrder(order: Map<string, number>, id: string) {
+  return order.get(id) ?? Number.MAX_SAFE_INTEGER
+}
+
+function firstByOrder<T extends { id: string }>(left: T[], right: T[], order: Map<string, number>) {
+  return [...left, ...right].sort((a, b) => {
+    const next = blockerOrder(order, a.id) - blockerOrder(order, b.id)
+    if (next !== 0) {
+      return next
+    }
+
+    return a.id.localeCompare(b.id)
+  })[0]
+}
+
+function pickView(data: SessionData, subagent: SubagentData, order: Map<string, number>): FooterView {
+  const permission = firstByOrder(data.permissions, listSubagentPermissions(subagent), order)
+  if (permission) {
+    return { type: "permission", request: permission }
+  }
+
+  const question = firstByOrder(data.questions, listSubagentQuestions(subagent), order)
+  if (question) {
+    return { type: "question", request: question }
+  }
+
+  return { type: "prompt" }
+}
+
+function composeFooter(input: {
+  patch?: FooterPatch
+  subagent?: FooterSubagentState
+  current: FooterView
+  previous: FooterView
+}) {
+  let footer: FooterOutput | undefined
+
+  if (input.subagent) {
+    footer = {
+      ...(footer ?? {}),
+      subagent: input.subagent,
+    }
+  }
+
+  if (!sameView(input.previous, input.current)) {
+    footer = {
+      ...(footer ?? {}),
+      view: input.current,
+    }
+  }
+
+  if (input.current.type !== "prompt") {
+    footer = {
+      ...(footer ?? {}),
+      patch: {
+        ...(input.patch ?? {}),
+        status: blockerStatus(input.current),
+      },
+    }
+    return footer
+  }
+
+  if (input.patch) {
+    footer = {
+      ...(footer ?? {}),
+      patch: input.patch,
+    }
+    return footer
+  }
+
+  if (input.previous.type !== "prompt") {
+    footer = {
+      ...(footer ?? {}),
+      patch: {
+        status: "",
+      },
+    }
+  }
+
+  return footer
+}
+
+function sameTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) {
+  if (!a || !b) {
+    return false
+  }
+
+  return (
+    a.sessionID === b.sessionID &&
+    a.partID === b.partID &&
+    a.callID === b.callID &&
+    a.label === b.label &&
+    a.description === b.description &&
+    a.status === b.status &&
+    a.title === b.title &&
+    a.toolCalls === b.toolCalls &&
+    a.lastUpdatedAt === b.lastUpdatedAt
+  )
+}
+
+function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) {
+  const before = new Map(prev.map((item) => [item.sessionID, item]))
+  const after = new Map(next.map((item) => [item.sessionID, item]))
+
+  for (const [sessionID, tab] of after) {
+    if (sameTab(before.get(sessionID), tab)) {
+      continue
+    }
+
+    trace?.write("subagent.tab", {
+      sessionID,
+      tab,
+    })
+  }
+
+  for (const sessionID of before.keys()) {
+    if (after.has(sessionID)) {
+      continue
+    }
+
+    trace?.write("subagent.tab", {
+      sessionID,
+      cleared: true,
+    })
+  }
+}
+
 // Opens an SDK event subscription and returns a SessionTransport.
 //
 // The background `watch` loop consumes every SDK event, runs it through the
@@ -191,10 +379,169 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
   }
 
   let data = createSessionData()
+  let subagent = createSubagentData()
   let wait: Wait | undefined
   let tick = 0
   let fault: unknown
   let closed = false
+  let footerView: FooterView = { type: "prompt" }
+  let blockerTick = 0
+  let selectedSubagent: string | undefined
+  const blockers = new Map<string, number>()
+
+  const currentSubagentState = () => {
+    if (selectedSubagent && !subagent.tabs.has(selectedSubagent)) {
+      selectedSubagent = undefined
+    }
+
+    return snapshotSelectedSubagentData(subagent, selectedSubagent)
+  }
+
+  const seedBlocker = (id: string) => {
+    if (blockers.has(id)) {
+      return
+    }
+
+    blockerTick += 1
+    blockers.set(id, blockerTick)
+  }
+
+  const trackBlocker = (event: Event) => {
+    if (event.type !== "permission.asked" && event.type !== "question.asked") {
+      return
+    }
+
+    if (event.properties.sessionID !== input.sessionID && !subagent.tabs.has(event.properties.sessionID)) {
+      return
+    }
+
+    seedBlocker(event.properties.id)
+  }
+
+  const releaseBlocker = (event: Event) => {
+    if (
+      event.type !== "permission.replied" &&
+      event.type !== "question.replied" &&
+      event.type !== "question.rejected"
+    ) {
+      return
+    }
+
+    blockers.delete(event.properties.requestID)
+  }
+
+  const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => {
+    const current = pickView(data, subagent, blockers)
+    const footer = composeFooter({
+      patch,
+      subagent: nextSubagent,
+      current,
+      previous: footerView,
+    })
+
+    if (commits.length === 0 && !footer) {
+      footerView = current
+      return
+    }
+
+    input.trace?.write("reduce.output", {
+      commits,
+      footer: traceFooterOutput(footer),
+    })
+    writeSessionOutput(
+      {
+        footer: input.footer,
+        trace: input.trace,
+      },
+      {
+        commits,
+        footer,
+      },
+    )
+    footerView = current
+  }
+
+  const bootstrap = async () => {
+    const [messages, children, permissions, questions] = await Promise.all([
+      input.sdk.session
+        .messages({
+          sessionID: input.sessionID,
+          limit: SUBAGENT_BOOTSTRAP_LIMIT,
+        })
+        .then((x) => x.data ?? [])
+        .catch(() => []),
+      input.sdk.session
+        .children({
+          sessionID: input.sessionID,
+        })
+        .then((x) => x.data ?? [])
+        .catch(() => []),
+      input.sdk.permission
+        .list()
+        .then((x) => x.data ?? [])
+        .catch(() => []),
+      input.sdk.question
+        .list()
+        .then((x) => x.data ?? [])
+        .catch(() => []),
+    ])
+
+    bootstrapSessionData({
+      data,
+      messages,
+      permissions: permissions.filter((item) => item.sessionID === input.sessionID),
+      questions: questions.filter((item) => item.sessionID === input.sessionID),
+    })
+    bootstrapSubagentData({
+      data: subagent,
+      messages,
+      children,
+      permissions,
+      questions,
+    })
+
+    const callSessions = [
+      ...new Set(
+        listSubagentPermissions(subagent)
+          .filter((item) => item.tool && item.metadata?.input === undefined)
+          .map((item) => item.sessionID),
+      ),
+    ]
+    if (callSessions.length > 0) {
+      await Promise.all(
+        callSessions.map(async (sessionID) => {
+          const messages = await input.sdk.session
+            .messages({
+              sessionID,
+              limit: SUBAGENT_CALL_BOOTSTRAP_LIMIT,
+            })
+            .then((x) => x.data ?? [])
+            .catch(() => [])
+
+          bootstrapSubagentCalls({
+            data: subagent,
+            sessionID,
+            messages,
+          })
+        }),
+      )
+    }
+
+    for (const request of [
+      ...data.permissions,
+      ...listSubagentPermissions(subagent),
+      ...data.questions,
+      ...listSubagentQuestions(subagent),
+    ].sort((a, b) => a.id.localeCompare(b.id))) {
+      seedBlocker(request.id)
+    }
+
+    const snapshot = currentSubagentState()
+    traceTabs(input.trace, [], snapshot.tabs)
+    syncFooter([], undefined, snapshot)
+  }
+
+  await bootstrap()
 
   const idle = async () => {
     try {
@@ -252,16 +599,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
   const flush = (type: "turn.abort" | "turn.cancel") => {
     const commits: StreamCommit[] = []
     flushInterrupted(data, commits)
-    writeSessionOutput(
-      {
-        footer: input.footer,
-        trace: input.trace,
-      },
-      {
-        data,
-        commits,
-      },
-    )
+    syncFooter(commits)
     input.trace?.write(type, {
       sessionID: input.sessionID,
     })
@@ -276,6 +614,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
 
         const event = item as Event
         input.trace?.write("recv.event", event)
+        trackBlocker(event)
+        const prevTabs = event.type === "message.part.updated" ? listSubagentTabs(subagent) : undefined
         const next = reduceSessionData({
           data,
           event,
@@ -285,20 +625,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         })
         data = next.data
 
-        if (next.commits.length > 0 || next.footer?.patch || next.footer?.view) {
-          input.trace?.write("reduce.output", {
-            commits: next.commits,
-            footer: next.footer,
-          })
+        const subagentChanged = reduceSubagentData({
+          data: subagent,
+          event,
+          sessionID: input.sessionID,
+          thinking: input.thinking,
+          limits: input.limits(),
+        })
+        if (subagentChanged && prevTabs) {
+          traceTabs(input.trace, prevTabs, listSubagentTabs(subagent))
         }
+        releaseBlocker(event)
 
-        writeSessionOutput(
-          {
-            footer: input.footer,
-            trace: input.trace,
-          },
-          next,
-        )
+        syncFooter(next.commits, next.footer?.patch, subagentChanged ? currentSubagentState() : undefined)
 
         touch(event)
         await mark(event)
@@ -328,6 +667,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       throw new Error("prompt already running")
     }
 
+    const prevTabs = listSubagentTabs(subagent)
+    if (clearFinishedSubagents(subagent)) {
+      const snapshot = currentSubagentState()
+      traceTabs(input.trace, prevTabs, snapshot.tabs)
+      syncFooter([], undefined, snapshot)
+    }
+
     const item = defer(tick)
     wait = item
     data.announced = false
@@ -425,6 +771,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     }
   }
 
+  const selectSubagent = (sessionID: string | undefined): void => {
+    const next = sessionID && subagent.tabs.has(sessionID) ? sessionID : undefined
+    if (selectedSubagent === next) {
+      return
+    }
+
+    selectedSubagent = next
+    syncFooter([], undefined, currentSubagentState())
+  }
+
   const close = async () => {
     if (closed) {
       return
@@ -439,6 +795,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
 
   return {
     runPromptTurn,
+    selectSubagent,
     close,
   }
 }

+ 122 - 6
packages/opencode/src/cli/cmd/run/stream.ts

@@ -1,12 +1,11 @@
-// Thin bridge between the session-data reducer output and the footer API.
+// Thin bridge between reducer output and the footer API.
 //
-// The reducer produces StreamCommit[] and an optional FooterOutput (patch +
-// view change). This module forwards them to footer.append() and
+// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
+// view + subagent state). This module forwards them to footer.append() and
 // footer.event() respectively, adding trace writes along the way. It also
 // defaults status updates to phase "running" if the caller didn't set a
 // phase -- a convenience so reducer code doesn't have to repeat that.
-import type { FooterApi, FooterPatch } from "./types"
-import type { SessionDataOutput } from "./session-data"
+import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
 
 type Trace = {
   write(type: string, data?: unknown): void
@@ -17,6 +16,11 @@ type OutputInput = {
   trace?: Trace
 }
 
+type StreamOutput = {
+  commits: StreamCommit[]
+  footer?: FooterOutput
+}
+
 // Default to "running" phase when a status string arrives without an explicit phase.
 function patch(next: FooterPatch): FooterPatch {
   if (typeof next.status === "string" && next.phase === undefined) {
@@ -29,8 +33,112 @@ function patch(next: FooterPatch): FooterPatch {
   return next
 }
 
+function summarize(value: unknown): unknown {
+  if (typeof value === "string") {
+    if (value.length <= 160) {
+      return value
+    }
+
+    return {
+      type: "string",
+      length: value.length,
+      preview: `${value.slice(0, 160)}...`,
+    }
+  }
+
+  if (Array.isArray(value)) {
+    return {
+      type: "array",
+      length: value.length,
+    }
+  }
+
+  if (!value || typeof value !== "object") {
+    return value
+  }
+
+  return {
+    type: "object",
+    keys: Object.keys(value),
+  }
+}
+
+function traceCommit(commit: StreamCommit) {
+  return {
+    ...commit,
+    text: summarize(commit.text),
+    textLength: commit.text.length,
+    part: commit.part
+      ? {
+          id: commit.part.id,
+          sessionID: commit.part.sessionID,
+          messageID: commit.part.messageID,
+          callID: commit.part.callID,
+          tool: commit.part.tool,
+          state: {
+            status: commit.part.state.status,
+            title: "title" in commit.part.state ? summarize(commit.part.state.title) : undefined,
+            error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
+            time: "time" in commit.part.state ? summarize(commit.part.state.time) : undefined,
+            input: summarize(commit.part.state.input),
+            metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
+          },
+        }
+      : undefined,
+  }
+}
+
+export function traceSubagentState(state: FooterSubagentState) {
+  return {
+    tabs: state.tabs,
+    details: Object.fromEntries(
+      Object.entries(state.details).map(([sessionID, detail]) => [
+        sessionID,
+        {
+          sessionID,
+          commits: detail.commits.map(traceCommit),
+        },
+      ]),
+    ),
+    permissions: state.permissions.map((item) => ({
+      id: item.id,
+      sessionID: item.sessionID,
+      permission: item.permission,
+      patterns: item.patterns,
+      tool: item.tool,
+      metadata: item.metadata
+        ? {
+            keys: Object.keys(item.metadata),
+            input: summarize(item.metadata.input),
+          }
+        : undefined,
+    })),
+    questions: state.questions.map((item) => ({
+      id: item.id,
+      sessionID: item.sessionID,
+      questions: item.questions.map((question) => ({
+        header: question.header,
+        question: question.question,
+        options: question.options.length,
+        multiple: question.multiple,
+      })),
+    })),
+  }
+}
+
+export function traceFooterOutput(footer?: FooterOutput) {
+  if (!footer?.subagent) {
+    return footer
+  }
+
+  return {
+    ...footer,
+    subagent: traceSubagentState(footer.subagent),
+  }
+}
+
 // Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
-export function writeSessionOutput(input: OutputInput, out: SessionDataOutput): void {
+export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
   for (const commit of out.commits) {
     input.trace?.write("ui.commit", commit)
     input.footer.append(commit)
@@ -45,6 +153,14 @@ export function writeSessionOutput(input: OutputInput, out: SessionDataOutput):
     })
   }
 
+  if (out.footer?.subagent) {
+    input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
+    input.footer.event({
+      type: "stream.subagent",
+      state: out.footer.subagent,
+    })
+  }
+
   if (!out.footer?.view) {
     return
   }

+ 715 - 0
packages/opencode/src/cli/cmd/run/subagent-data.ts

@@ -0,0 +1,715 @@
+import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
+import * as Locale from "../../../util/locale"
+import {
+  bootstrapSessionData,
+  createSessionData,
+  formatError,
+  reduceSessionData,
+  type SessionData,
+} from "./session-data"
+import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
+
+export const SUBAGENT_BOOTSTRAP_LIMIT = 200
+export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
+
+const SUBAGENT_COMMIT_LIMIT = 80
+const SUBAGENT_CALL_LIMIT = 32
+const SUBAGENT_ROLE_LIMIT = 32
+const SUBAGENT_ERROR_LIMIT = 16
+const SUBAGENT_ECHO_LIMIT = 8
+
+type SessionMessage = {
+  parts: Part[]
+}
+
+type Frame = {
+  key: string
+  commit: StreamCommit
+}
+
+type DetailState = {
+  sessionID: string
+  data: SessionData
+  frames: Frame[]
+}
+
+export type SubagentData = {
+  tabs: Map<string, FooterSubagentTab>
+  details: Map<string, DetailState>
+}
+
+export type BootstrapSubagentInput = {
+  data: SubagentData
+  messages: SessionMessage[]
+  children: Array<{ id: string; title?: string }>
+  permissions: PermissionRequest[]
+  questions: QuestionRequest[]
+}
+
+function createDetail(sessionID: string): DetailState {
+  return {
+    sessionID,
+    data: createSessionData({
+      includeUserText: true,
+    }),
+    frames: [],
+  }
+}
+
+function ensureDetail(data: SubagentData, sessionID: string) {
+  const current = data.details.get(sessionID)
+  if (current) {
+    return current
+  }
+
+  const next = createDetail(sessionID)
+  data.details.set(sessionID, next)
+  return next
+}
+
+function sameTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab) {
+  if (!a) {
+    return false
+  }
+
+  return (
+    a.sessionID === b.sessionID &&
+    a.partID === b.partID &&
+    a.callID === b.callID &&
+    a.label === b.label &&
+    a.description === b.description &&
+    a.status === b.status &&
+    a.title === b.title &&
+    a.toolCalls === b.toolCalls &&
+    a.lastUpdatedAt === b.lastUpdatedAt
+  )
+}
+
+function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
+  return (
+    left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
+  )
+}
+
+function sameCommit(left: StreamCommit, right: StreamCommit) {
+  return (
+    left.kind === right.kind &&
+    left.text === right.text &&
+    left.phase === right.phase &&
+    left.source === right.source &&
+    left.messageID === right.messageID &&
+    left.partID === right.partID &&
+    left.tool === right.tool &&
+    left.interrupted === right.interrupted &&
+    left.toolState === right.toolState &&
+    left.toolError === right.toolError
+  )
+}
+
+function text(value: unknown) {
+  if (typeof value !== "string") {
+    return
+  }
+
+  const next = value.trim()
+  return next || undefined
+}
+
+function num(value: unknown) {
+  if (typeof value === "number" && Number.isFinite(value)) {
+    return value
+  }
+
+  return
+}
+
+function inputLabel(input: Record<string, unknown>) {
+  const description = text(input.description)
+  if (description) {
+    return description
+  }
+
+  const command = text(input.command)
+  if (command) {
+    return command
+  }
+
+  const filePath = text(input.filePath) ?? text(input.filepath)
+  if (filePath) {
+    return filePath
+  }
+
+  const pattern = text(input.pattern)
+  if (pattern) {
+    return pattern
+  }
+
+  const query = text(input.query)
+  if (query) {
+    return query
+  }
+
+  const url = text(input.url)
+  if (url) {
+    return url
+  }
+
+  const path = text(input.path)
+  if (path) {
+    return path
+  }
+
+  const prompt = text(input.prompt)
+  if (prompt) {
+    return prompt
+  }
+
+  return
+}
+
+function stateTitle(part: ToolPart) {
+  return text("title" in part.state ? part.state.title : undefined)
+}
+
+function callKey(messageID: string | undefined, callID: string | undefined) {
+  if (!messageID || !callID) {
+    return
+  }
+
+  return `${messageID}:${callID}`
+}
+
+function recent<T>(input: Iterable<T>, limit: number) {
+  const list = [...input]
+  return list.slice(Math.max(0, list.length - limit))
+}
+
+function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
+  const out = new Map<K, V>()
+  for (const [key, value] of source) {
+    if (!keep.has(key)) {
+      continue
+    }
+
+    out.set(key, value)
+  }
+  return out
+}
+
+function compactToolPart(part: ToolPart): ToolPart {
+  return {
+    id: part.id,
+    type: "tool",
+    sessionID: part.sessionID,
+    messageID: part.messageID,
+    callID: part.callID,
+    tool: part.tool,
+    state: {
+      status: part.state.status,
+      input: part.state.input,
+      metadata: "metadata" in part.state ? part.state.metadata : undefined,
+      time: "time" in part.state ? part.state.time : undefined,
+      title: "title" in part.state ? part.state.title : undefined,
+      error: "error" in part.state ? part.state.error : undefined,
+    },
+  } as ToolPart
+}
+
+function compactCommit(commit: StreamCommit): StreamCommit {
+  if (!commit.part) {
+    return commit
+  }
+
+  return {
+    ...commit,
+    part: compactToolPart(commit.part),
+  }
+}
+
+function stateUpdatedAt(part: ToolPart) {
+  if (!("time" in part.state)) {
+    return Date.now()
+  }
+
+  const time = part.state.time
+  if (!("end" in time)) {
+    return time.start ?? Date.now()
+  }
+
+  return time.end ?? time.start ?? Date.now()
+}
+
+function metadata(part: ToolPart, key: string) {
+  return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
+}
+
+function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
+  const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
+  const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
+  const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
+
+  return {
+    sessionID,
+    partID: part.id,
+    callID: part.callID,
+    label,
+    description,
+    status,
+    title: stateTitle(part),
+    toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
+    lastUpdatedAt: stateUpdatedAt(part),
+  }
+}
+
+function taskSessionID(part: ToolPart) {
+  return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
+}
+
+function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
+  if (part.tool !== "task") {
+    return false
+  }
+
+  const sessionID = taskSessionID(part)
+  if (!sessionID) {
+    return false
+  }
+
+  if (children && children.size > 0 && !children.has(sessionID)) {
+    return false
+  }
+
+  const next = taskTab(part, sessionID)
+  if (sameTab(data.tabs.get(sessionID), next)) {
+    ensureDetail(data, sessionID)
+    return false
+  }
+
+  data.tabs.set(sessionID, next)
+  ensureDetail(data, sessionID)
+  return true
+}
+
+function frameKey(commit: StreamCommit) {
+  if (commit.partID) {
+    return `${commit.kind}:${commit.partID}:${commit.phase}`
+  }
+
+  if (commit.messageID) {
+    return `${commit.kind}:${commit.messageID}:${commit.phase}`
+  }
+
+  return `${commit.kind}:${commit.phase}:${commit.text}`
+}
+
+function limitFrames(detail: DetailState) {
+  if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
+    return
+  }
+
+  detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
+}
+
+function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
+  if (current.phase !== "progress" || next.phase !== "progress") {
+    if (sameCommit(current, next)) {
+      return current
+    }
+
+    return next
+  }
+
+  const merged = {
+    ...current,
+    ...next,
+    text: current.text + next.text,
+  }
+
+  if (sameCommit(current, merged)) {
+    return current
+  }
+
+  return merged
+}
+
+function appendCommits(detail: DetailState, commits: StreamCommit[]) {
+  let changed = false
+
+  for (const commit of commits.map(compactCommit)) {
+    const key = frameKey(commit)
+    const index = detail.frames.findIndex((item) => item.key === key)
+    if (index === -1) {
+      detail.frames.push({
+        key,
+        commit,
+      })
+      changed = true
+      continue
+    }
+
+    const next = mergeLiveCommit(detail.frames[index].commit, commit)
+    if (sameCommit(detail.frames[index].commit, next)) {
+      continue
+    }
+
+    detail.frames[index] = {
+      key,
+      commit: next,
+    }
+    changed = true
+  }
+
+  if (changed) {
+    limitFrames(detail)
+  }
+
+  return changed
+}
+
+function ensureBlockerTab(
+  data: SubagentData,
+  sessionID: string,
+  title: string | undefined,
+  kind: "permission" | "question",
+) {
+  if (data.tabs.has(sessionID)) {
+    ensureDetail(data, sessionID)
+    return false
+  }
+
+  data.tabs.set(sessionID, {
+    sessionID,
+    partID: `bootstrap:${sessionID}`,
+    callID: `bootstrap:${sessionID}`,
+    label: text(title) ?? Locale.titlecase(kind),
+    description: kind === "permission" ? "Pending permission" : "Pending question",
+    status: "running",
+    lastUpdatedAt: Date.now(),
+  })
+  ensureDetail(data, sessionID)
+  return true
+}
+
+function compactCallMap(detail: DetailState) {
+  const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
+
+  for (const request of detail.data.permissions) {
+    const key = callKey(request.tool?.messageID, request.tool?.callID)
+    if (key) {
+      keep.add(key)
+    }
+  }
+
+  for (const item of detail.frames) {
+    const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
+    if (key) {
+      keep.add(key)
+    }
+  }
+
+  return copyMap(detail.data.call, keep)
+}
+
+function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
+  const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
+  return copyMap(data.echo, keys)
+}
+
+function compactIDs(detail: DetailState) {
+  return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
+}
+
+function compactDetail(detail: DetailState) {
+  const next = createSessionData({
+    includeUserText: true,
+  })
+  const activePartIDs = new Set(detail.data.part.keys())
+  const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
+  const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
+  const messageIDs = new Set([
+    ...[...activePartIDs]
+      .map((partID) => detail.data.msg.get(partID))
+      .filter((item): item is string => typeof item === "string"),
+    ...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
+  ])
+
+  next.announced = detail.data.announced
+  next.permissions = detail.data.permissions
+  next.questions = detail.data.questions
+  next.ids = compactIDs(detail)
+  next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
+  next.call = compactCallMap(detail)
+  next.role = copyMap(detail.data.role, messageIDs)
+  next.msg = copyMap(detail.data.msg, activePartIDs)
+  next.part = copyMap(detail.data.part, activePartIDs)
+  next.text = copyMap(detail.data.text, activePartIDs)
+  next.sent = copyMap(detail.data.sent, activePartIDs)
+  next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
+  next.echo = compactEchoMap(detail.data, messageIDs)
+  detail.data = next
+}
+
+function applyChildEvent(input: {
+  detail: DetailState
+  event: Event
+  thinking: boolean
+  limits: Record<string, number>
+}) {
+  const beforePermissions = input.detail.data.permissions.slice()
+  const beforeQuestions = input.detail.data.questions.slice()
+  const out = reduceSessionData({
+    data: input.detail.data,
+    event: input.event,
+    sessionID: input.detail.sessionID,
+    thinking: input.thinking,
+    limits: input.limits,
+  })
+  const changed = appendCommits(input.detail, out.commits)
+  compactDetail(input.detail)
+
+  return (
+    changed ||
+    !sameQueue(beforePermissions, input.detail.data.permissions) ||
+    !sameQueue(beforeQuestions, input.detail.data.questions)
+  )
+}
+
+function knownSession(data: SubagentData, sessionID: string) {
+  return data.tabs.has(sessionID)
+}
+
+export function listSubagentPermissions(data: SubagentData) {
+  return [...data.details.values()].flatMap((detail) => detail.data.permissions)
+}
+
+export function listSubagentQuestions(data: SubagentData) {
+  return [...data.details.values()].flatMap((detail) => detail.data.questions)
+}
+
+export function createSubagentData(): SubagentData {
+  return {
+    tabs: new Map(),
+    details: new Map(),
+  }
+}
+
+function snapshotDetail(detail: DetailState) {
+  return {
+    sessionID: detail.sessionID,
+    commits: detail.frames.map((item) => item.commit),
+  }
+}
+
+export function listSubagentTabs(data: SubagentData) {
+  return [...data.tabs.values()].sort((a, b) => {
+    const active = Number(b.status === "running") - Number(a.status === "running")
+    if (active !== 0) {
+      return active
+    }
+
+    return b.lastUpdatedAt - a.lastUpdatedAt
+  })
+}
+
+function snapshotQueues(data: SubagentData) {
+  return {
+    permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
+    questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
+  }
+}
+
+export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
+  return {
+    tabs: listSubagentTabs(data),
+    details: Object.fromEntries(
+      [...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)]),
+    ),
+    ...snapshotQueues(data),
+  }
+}
+
+export function snapshotSelectedSubagentData(
+  data: SubagentData,
+  selectedSessionID: string | undefined,
+): FooterSubagentState {
+  const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
+
+  return {
+    tabs: listSubagentTabs(data),
+    details: detail ? { [detail.sessionID]: snapshotDetail(detail) } : {},
+    ...snapshotQueues(data),
+  }
+}
+
+export function bootstrapSubagentData(input: BootstrapSubagentInput) {
+  const child = new Map(input.children.map((item) => [item.id, item]))
+  const children = new Set(child.keys())
+  let changed = false
+
+  for (const message of input.messages) {
+    for (const part of message.parts) {
+      if (part.type !== "tool") {
+        continue
+      }
+
+      changed = syncTaskTab(input.data, part, children) || changed
+    }
+  }
+
+  for (const item of input.permissions) {
+    if (!children.has(item.sessionID)) {
+      continue
+    }
+
+    changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
+  }
+
+  for (const item of input.questions) {
+    if (!children.has(item.sessionID)) {
+      continue
+    }
+
+    changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
+  }
+
+  for (const sessionID of input.data.tabs.keys()) {
+    const detail = ensureDetail(input.data, sessionID)
+    const beforePermissions = detail.data.permissions.slice()
+    const beforeQuestions = detail.data.questions.slice()
+
+    bootstrapSessionData({
+      data: detail.data,
+      messages: [],
+      permissions: input.permissions
+        .filter((item) => item.sessionID === sessionID)
+        .sort((a, b) => a.id.localeCompare(b.id)),
+      questions: input.questions
+        .filter((item) => item.sessionID === sessionID)
+        .sort((a, b) => a.id.localeCompare(b.id)),
+    })
+    compactDetail(detail)
+
+    changed =
+      !sameQueue(beforePermissions, detail.data.permissions) ||
+      !sameQueue(beforeQuestions, detail.data.questions) ||
+      changed
+  }
+
+  return changed
+}
+
+export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
+  if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
+    return false
+  }
+
+  const detail = ensureDetail(input.data, input.sessionID)
+  const beforePermissions = detail.data.permissions.slice()
+  const beforeQuestions = detail.data.questions.slice()
+  const beforeCallCount = detail.data.call.size
+  bootstrapSessionData({
+    data: detail.data,
+    messages: input.messages,
+    permissions: detail.data.permissions,
+    questions: detail.data.questions,
+  })
+  compactDetail(detail)
+
+  return (
+    beforeCallCount !== detail.data.call.size ||
+    !sameQueue(beforePermissions, detail.data.permissions) ||
+    !sameQueue(beforeQuestions, detail.data.questions)
+  )
+}
+
+export function clearFinishedSubagents(data: SubagentData) {
+  let changed = false
+
+  for (const [sessionID, tab] of [...data.tabs.entries()]) {
+    if (tab.status === "running") {
+      continue
+    }
+
+    data.tabs.delete(sessionID)
+    data.details.delete(sessionID)
+    changed = true
+  }
+
+  return changed
+}
+
+export function reduceSubagentData(input: {
+  data: SubagentData
+  event: Event
+  sessionID: string
+  thinking: boolean
+  limits: Record<string, number>
+}) {
+  const event = input.event
+
+  if (event.type === "message.part.updated") {
+    const part = event.properties.part
+    if (part.sessionID === input.sessionID) {
+      if (part.type !== "tool") {
+        return false
+      }
+
+      return syncTaskTab(input.data, part)
+    }
+  }
+
+  const sessionID =
+    event.type === "message.updated" ||
+    event.type === "message.part.delta" ||
+    event.type === "permission.asked" ||
+    event.type === "permission.replied" ||
+    event.type === "question.asked" ||
+    event.type === "question.replied" ||
+    event.type === "question.rejected" ||
+    event.type === "session.error" ||
+    event.type === "session.status"
+      ? event.properties.sessionID
+      : event.type === "message.part.updated"
+        ? event.properties.part.sessionID
+        : undefined
+
+  if (!sessionID || !knownSession(input.data, sessionID)) {
+    return false
+  }
+
+  const detail = ensureDetail(input.data, sessionID)
+  if (event.type === "session.status") {
+    if (event.properties.status.type !== "retry") {
+      return false
+    }
+
+    return appendCommits(detail, [
+      {
+        kind: "error",
+        text: event.properties.status.message,
+        phase: "start",
+        source: "system",
+        messageID: `retry:${event.properties.status.attempt}`,
+      },
+    ])
+  }
+
+  if (event.type === "session.error" && event.properties.error) {
+    return appendCommits(detail, [
+      {
+        kind: "error",
+        text: formatError(event.properties.error),
+        phase: "start",
+        source: "system",
+        messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
+      },
+    ])
+  }
+
+  return applyChildEvent({
+    detail,
+    event,
+    thinking: input.thinking,
+    limits: input.limits,
+  })
+}

+ 31 - 0
packages/opencode/src/cli/cmd/run/types.ts

@@ -92,10 +92,37 @@ export type FooterView =
   | { type: "permission"; request: PermissionRequest }
   | { type: "question"; request: QuestionRequest }
 
+export type FooterPromptRoute = { type: "composer" } | { type: "subagent"; sessionID: string }
+
+export type FooterSubagentTab = {
+  sessionID: string
+  partID: string
+  callID: string
+  label: string
+  description: string
+  status: "running" | "completed" | "error"
+  title?: string
+  toolCalls?: number
+  lastUpdatedAt: number
+}
+
+export type FooterSubagentDetail = {
+  sessionID: string
+  commits: StreamCommit[]
+}
+
+export type FooterSubagentState = {
+  tabs: FooterSubagentTab[]
+  details: Record<string, FooterSubagentDetail>
+  permissions: PermissionRequest[]
+  questions: QuestionRequest[]
+}
+
 // The reducer emits this alongside scrollback commits so the footer can update in the same frame.
 export type FooterOutput = {
   patch?: FooterPatch
   view?: FooterView
+  subagent?: FooterSubagentState
 }
 
 // Typed messages sent to RunFooter.event(). The prompt queue and stream
@@ -137,6 +164,10 @@ export type FooterEvent =
       type: "stream.view"
       view: FooterView
     }
+  | {
+      type: "stream.subagent"
+      state: FooterSubagentState
+    }
 
 export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
 

+ 2 - 2
packages/opencode/src/cli/cmd/tui/component/spinner.tsx

@@ -5,7 +5,7 @@ import type { JSX } from "@opentui/solid"
 import type { RGBA } from "@opentui/core"
 import "opentui-spinner/solid"
 
-const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
 
 export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
   const { theme } = useTheme()
@@ -14,7 +14,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
   return (
     <Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
       <box flexDirection="row" gap={1}>
-        <spinner frames={frames} interval={80} color={color()} />
+        <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
         <Show when={props.children}>
           <text fg={color()}>{props.children}</text>
         </Show>

+ 6 - 0
packages/opencode/test/cli/run/footer.test.ts

@@ -0,0 +1,6 @@
+import { expect, test } from "bun:test"
+import { RunFooter } from "../../../src/cli/cmd/run/footer"
+
+test("run footer class loads", () => {
+  expect(typeof RunFooter).toBe("function")
+})

+ 6 - 0
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -0,0 +1,6 @@
+import { expect, test } from "bun:test"
+import { RunFooterView } from "../../../src/cli/cmd/run/footer.view"
+
+test("run footer view loads", () => {
+  expect(typeof RunFooterView).toBe("function")
+})

+ 63 - 3
packages/opencode/test/cli/run/stream.test.ts

@@ -37,7 +37,6 @@ describe("run stream bridge", () => {
         footer: out.api,
       },
       {
-        data: {} as never,
         commits,
       },
     )
@@ -53,7 +52,6 @@ describe("run stream bridge", () => {
         footer: out.api,
       },
       {
-        data: {} as never,
         commits: [],
         footer: {
           patch: {
@@ -82,7 +80,6 @@ describe("run stream bridge", () => {
         footer: out.api,
       },
       {
-        data: {} as never,
         commits: [],
         footer: {
           view: {
@@ -101,4 +98,67 @@ describe("run stream bridge", () => {
       },
     ])
   })
+
+  test("forwards subagent footer snapshots as stream.subagent events", () => {
+    const out = footer()
+
+    writeSessionOutput(
+      {
+        footer: out.api,
+      },
+      {
+        commits: [],
+        footer: {
+          subagent: {
+            tabs: [
+              {
+                sessionID: "child-1",
+                partID: "part-1",
+                callID: "call-1",
+                label: "Explore",
+                description: "Scan reducer paths",
+                status: "running",
+                lastUpdatedAt: 1,
+              },
+            ],
+            details: {
+              "child-1": {
+                sessionID: "child-1",
+                commits: [],
+              },
+            },
+            permissions: [],
+            questions: [],
+          },
+        },
+      },
+    )
+
+    expect(out.events).toEqual([
+      {
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            {
+              sessionID: "child-1",
+              partID: "part-1",
+              callID: "call-1",
+              label: "Explore",
+              description: "Scan reducer paths",
+              status: "running",
+              lastUpdatedAt: 1,
+            },
+          ],
+          details: {
+            "child-1": {
+              sessionID: "child-1",
+              commits: [],
+            },
+          },
+          permissions: [],
+          questions: [],
+        },
+      },
+    ])
+  })
 })

+ 239 - 0
packages/opencode/test/cli/run/stream.transport.test.ts

@@ -139,6 +139,13 @@ function sdk(
   opt: {
     promptAsync?: (input: unknown, opt?: { signal?: AbortSignal }) => Promise<void>
     status?: () => Promise<{ data?: Record<string, { type: string }> }>
+    messages?: (input: {
+      sessionID: string
+      limit?: number
+    }) => Promise<{ data?: Array<{ info: unknown; parts: unknown[] }> }>
+    children?: () => Promise<{ data?: Array<{ id: string }> }>
+    permissions?: () => Promise<{ data?: unknown[] }>
+    questions?: () => Promise<{ data?: unknown[] }>
   } = {},
 ) {
   return {
@@ -150,11 +157,235 @@ function sdk(
     session: {
       promptAsync: opt.promptAsync ?? (async () => {}),
       status: opt.status ?? (async () => ({ data: {} })),
+      messages: opt.messages ?? (async () => ({ data: [] })),
+      children: opt.children ?? (async () => ({ data: [] })),
+    },
+    permission: {
+      list: opt.permissions ?? (async () => ({ data: [] })),
+    },
+    question: {
+      list: opt.questions ?? (async () => ({ data: [] })),
     },
   } as unknown as OpencodeClient
 }
 
 describe("run stream transport", () => {
+  test("bootstraps subagent tabs from parent task parts", async () => {
+    const src = feed()
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        messages: async ({ sessionID }) => {
+          if (sessionID !== "session-1") {
+            throw new Error("unexpected child bootstrap")
+          }
+
+          return {
+            data: [
+              {
+                info: {
+                  id: "msg-1",
+                  role: "assistant",
+                },
+                parts: [
+                  {
+                    id: "task-1",
+                    sessionID: "session-1",
+                    messageID: "msg-1",
+                    type: "tool",
+                    callID: "call-1",
+                    tool: "task",
+                    state: {
+                      status: "running",
+                      input: {
+                        description: "Explore run folder",
+                        subagent_type: "explore",
+                      },
+                      metadata: {
+                        sessionId: "child-1",
+                      },
+                      time: {
+                        start: 1,
+                      },
+                    },
+                  },
+                ],
+              },
+            ],
+          }
+        },
+        children: async () => ({
+          data: [{ id: "child-1" }],
+        }),
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      expect(ui.events).toContainEqual({
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            expect.objectContaining({
+              sessionID: "child-1",
+              label: "Explore",
+              description: "Explore run folder",
+              status: "running",
+            }),
+          ],
+          details: {},
+          permissions: [],
+          questions: [],
+        },
+      })
+
+      transport.selectSubagent("child-1")
+
+      expect(ui.events).toContainEqual({
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            expect.objectContaining({
+              sessionID: "child-1",
+              label: "Explore",
+              description: "Explore run folder",
+              status: "running",
+            }),
+          ],
+          details: {
+            "child-1": {
+              sessionID: "child-1",
+              commits: [],
+            },
+          },
+          permissions: [],
+          questions: [],
+        },
+      })
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("bootstraps resumed child permission input without recent parent task parts", async () => {
+    const src = feed()
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        messages: async ({ sessionID }) => {
+          if (sessionID === "session-1") {
+            return { data: [] }
+          }
+
+          return {
+            data: [
+              {
+                info: {
+                  id: "msg-child-1",
+                  role: "assistant",
+                },
+                parts: [
+                  {
+                    id: "edit-1",
+                    sessionID: "child-1",
+                    messageID: "msg-child-1",
+                    type: "tool",
+                    callID: "call-edit-1",
+                    tool: "edit",
+                    state: {
+                      status: "running",
+                      input: {
+                        filePath: "src/run/subagent-data.ts",
+                        diff: "@@ -1 +1 @@",
+                      },
+                      time: {
+                        start: 1,
+                      },
+                    },
+                  },
+                ],
+              },
+            ],
+          }
+        },
+        children: async () => ({
+          data: [{ id: "child-1" }],
+        }),
+        permissions: async () => ({
+          data: [
+            {
+              id: "perm-1",
+              sessionID: "child-1",
+              permission: "edit",
+              patterns: ["src/run/subagent-data.ts"],
+              metadata: {},
+              always: [],
+              tool: {
+                messageID: "msg-child-1",
+                callID: "call-edit-1",
+              },
+            },
+          ],
+        }),
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      expect(ui.events).toContainEqual({
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            expect.objectContaining({
+              sessionID: "child-1",
+              status: "running",
+            }),
+          ],
+          details: {},
+          permissions: [
+            expect.objectContaining({
+              id: "perm-1",
+              sessionID: "child-1",
+              metadata: {
+                input: {
+                  filePath: "src/run/subagent-data.ts",
+                  diff: "@@ -1 +1 @@",
+                },
+              },
+            }),
+          ],
+          questions: [],
+        },
+      })
+
+      expect(ui.events).toContainEqual({
+        type: "stream.view",
+        view: {
+          type: "permission",
+          request: expect.objectContaining({
+            id: "perm-1",
+            metadata: {
+              input: {
+                filePath: "src/run/subagent-data.ts",
+                diff: "@@ -1 +1 @@",
+              },
+            },
+          }),
+        },
+      })
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
   test("respects the includeFiles flag when building prompt payloads", async () => {
     const src = feed()
     const ui = footer()
@@ -491,6 +722,14 @@ describe("run stream transport", () => {
         session: {
           promptAsync: async () => {},
           status: async () => ({ data: {} }),
+          messages: async () => ({ data: [] }),
+          children: async () => ({ data: [] }),
+        },
+        permission: {
+          list: async () => ({ data: [] }),
+        },
+        question: {
+          list: async () => ({ data: [] }),
         },
       } as unknown as OpencodeClient,
       sessionID: "session-1",

+ 367 - 0
packages/opencode/test/cli/run/subagent-data.test.ts

@@ -0,0 +1,367 @@
+import { describe, expect, test } from "bun:test"
+import { normalizeEntry } from "../../../src/cli/cmd/run/scrollback.format"
+import {
+  bootstrapSubagentData,
+  clearFinishedSubagents,
+  createSubagentData,
+  reduceSubagentData,
+  snapshotSelectedSubagentData,
+  snapshotSubagentData,
+} from "../../../src/cli/cmd/run/subagent-data"
+
+function taskMessage(sessionID: string, status: "running" | "completed" | "error" = "completed") {
+  return {
+    info: {
+      id: `msg-${sessionID}`,
+      role: "assistant",
+    },
+    parts: [
+      {
+        id: `part-${sessionID}`,
+        sessionID: "parent-1",
+        messageID: `msg-${sessionID}`,
+        type: "tool",
+        callID: `call-${sessionID}`,
+        tool: "task",
+        state: {
+          status,
+          input: {
+            description: "Scan reducer paths",
+            subagent_type: "explore",
+          },
+          title: "Reducer touchpoints",
+          metadata: {
+            sessionId: sessionID,
+            toolcalls: 4,
+          },
+          time: status === "running" ? { start: 1 } : { start: 1, end: 2 },
+        },
+      },
+    ],
+  } as const
+}
+
+function question(id: string, sessionID: string) {
+  return {
+    id,
+    sessionID,
+    questions: [
+      {
+        question: "Mode?",
+        header: "Mode",
+        options: [{ label: "Fast", description: "Quick pass" }],
+      },
+    ],
+  }
+}
+
+describe("run subagent data", () => {
+  test("bootstraps tabs and child blockers from parent task parts", () => {
+    const data = createSubagentData()
+
+    expect(
+      bootstrapSubagentData({
+        data,
+        messages: [taskMessage("child-1") as never],
+        children: [{ id: "child-1" }, { id: "child-2" }],
+        permissions: [
+          {
+            id: "perm-1",
+            sessionID: "child-1",
+            permission: "read",
+            patterns: ["src/**/*.ts"],
+            metadata: {},
+            always: [],
+          },
+          {
+            id: "perm-2",
+            sessionID: "other",
+            permission: "read",
+            patterns: ["src/**/*.ts"],
+            metadata: {},
+            always: [],
+          },
+        ],
+        questions: [question("question-1", "child-1"), question("question-2", "other")],
+      }),
+    ).toBe(true)
+
+    expect(snapshotSubagentData(data)).toEqual({
+      tabs: [
+        expect.objectContaining({
+          sessionID: "child-1",
+          label: "Explore",
+          description: "Scan reducer paths",
+          title: "Reducer touchpoints",
+          status: "completed",
+          toolCalls: 4,
+        }),
+      ],
+      details: {
+        "child-1": {
+          sessionID: "child-1",
+          commits: [],
+        },
+      },
+      permissions: [expect.objectContaining({ id: "perm-1", sessionID: "child-1" })],
+      questions: [expect.objectContaining({ id: "question-1", sessionID: "child-1" })],
+    })
+  })
+
+  test("reduces child text tool and blocker events into footer detail state", () => {
+    const data = createSubagentData()
+
+    bootstrapSubagentData({
+      data,
+      messages: [taskMessage("child-1", "running") as never],
+      children: [{ id: "child-1" }],
+      permissions: [],
+      questions: [],
+    })
+
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "txt-1",
+            messageID: "msg-user-1",
+            sessionID: "child-1",
+            type: "text",
+            text: "Inspect footer tabs",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.updated",
+        properties: {
+          sessionID: "child-1",
+          info: {
+            id: "msg-user-1",
+            role: "user",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.updated",
+        properties: {
+          sessionID: "child-1",
+          info: {
+            id: "msg-assistant-1",
+            role: "assistant",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "reason-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "reasoning",
+            text: "planning next steps",
+            time: { start: 1 },
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "tool-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "tool",
+            callID: "call-1",
+            tool: "bash",
+            state: {
+              status: "running",
+              input: {
+                command: "git status --short",
+              },
+              time: { start: 1 },
+            },
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "permission.asked",
+        properties: {
+          id: "perm-1",
+          sessionID: "child-1",
+          permission: "bash",
+          patterns: ["git status --short"],
+          metadata: {},
+          always: [],
+          tool: {
+            messageID: "msg-assistant-1",
+            callID: "call-1",
+          },
+        },
+      } as never,
+    })
+
+    const snapshot = snapshotSubagentData(data)
+
+    expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
+    expect(snapshot.details["child-1"]).toEqual({
+      sessionID: "child-1",
+      commits: expect.any(Array),
+    })
+    expect(snapshot.details["child-1"]?.commits.map((item) => normalizeEntry(item))).toEqual([
+      "› Inspect footer tabs",
+      "Thinking: planning next steps",
+      "# Shell\n$ git status --short",
+    ])
+    expect(snapshot.permissions).toEqual([
+      expect.objectContaining({
+        id: "perm-1",
+        metadata: {
+          input: {
+            command: "git status --short",
+          },
+        },
+      }),
+    ])
+    expect(snapshot.questions).toEqual([])
+  })
+
+  test("continues live child text streams", () => {
+    const data = createSubagentData()
+
+    bootstrapSubagentData({
+      data,
+      messages: [taskMessage("child-1", "running") as never],
+      children: [{ id: "child-1" }],
+      permissions: [],
+      questions: [],
+    })
+
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.updated",
+        properties: {
+          sessionID: "child-1",
+          info: {
+            id: "msg-assistant-1",
+            role: "assistant",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "txt-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "text",
+            text: "hello",
+          },
+        },
+      } as never,
+    })
+
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.delta",
+        properties: {
+          sessionID: "child-1",
+          messageID: "msg-assistant-1",
+          partID: "txt-1",
+          field: "text",
+          delta: " world",
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "txt-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "text",
+            text: "hello world",
+            time: { start: 1, end: 2 },
+          },
+        },
+      } as never,
+    })
+
+    expect(
+      snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits.map((item) => normalizeEntry(item)),
+    ).toEqual(["hello world"])
+  })
+
+  test("clears finished tabs on the next parent prompt", () => {
+    const data = createSubagentData()
+
+    bootstrapSubagentData({
+      data,
+      messages: [taskMessage("child-1", "completed") as never, taskMessage("child-2", "running") as never],
+      children: [{ id: "child-1" }, { id: "child-2" }],
+      permissions: [],
+      questions: [],
+    })
+
+    expect(clearFinishedSubagents(data)).toBe(true)
+    expect(snapshotSubagentData(data).tabs).toEqual([
+      expect.objectContaining({ sessionID: "child-2", status: "running" }),
+    ])
+  })
+})