فهرست منبع

run: replace subagent tabs with on-demand picker (#28508)

Move subagent navigation into the existing palette: a
"View subagents" command entry, a dedicated picker panel, and a
Down-arrow shortcut from the empty composer.
Simon Klee 3 ماه پیش
والد
کامیت
ed839846d1

+ 138 - 2
packages/opencode/src/cli/cmd/run/footer.command.tsx

@@ -6,7 +6,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
 import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
 import { formatBindings } from "./keymap.shared"
 import type { RunFooterTheme } from "./theme"
-import type { FooterKeybinds, RunCommand, RunInput, RunProvider } from "./types"
+import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
 
 type PanelEntry = RunFooterMenuItem & {
   category: string
@@ -15,6 +15,7 @@ type PanelEntry = RunFooterMenuItem & {
 
 type CommandEntry =
   | (PanelEntry & { action: "model" })
+  | (PanelEntry & { action: "subagent" })
   | (PanelEntry & { action: "variant.cycle" })
   | (PanelEntry & { action: "variant.list" })
   | (PanelEntry & { action: "slash"; name: string })
@@ -32,11 +33,19 @@ type VariantEntry = PanelEntry & {
   current: boolean
 }
 
+type SubagentEntry = PanelEntry & {
+  sessionID: string
+  current: boolean
+}
+
 type MenuState = ReturnType<typeof createFooterMenuState>
 
 const PANEL_PAD = 2
 const PANEL_LIST_ROWS = 10
-export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + 6
+const PANEL_FRAME_ROWS = 6
+export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS
+const SUBAGENT_LIST_ROWS = 12
+export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS
 const PANEL_PAGE = PANEL_LIST_ROWS - 1
 const PANEL_BORDER = {
   topLeft: "",
@@ -89,6 +98,18 @@ function categoryRank(category: string) {
   return 2
 }
 
+function subagentStatusLabel(status: FooterSubagentTab["status"]) {
+  if (status === "completed") {
+    return "done"
+  }
+
+  if (status === "error") {
+    return "error"
+  }
+
+  return "running"
+}
+
 function handleKey(input: {
   event: KeyEvent
   menu: MenuState
@@ -273,10 +294,12 @@ function PanelShell(props: {
 export function RunCommandMenuBody(props: {
   theme: Accessor<RunFooterTheme>
   commands: Accessor<RunCommand[] | undefined>
+  subagents: Accessor<FooterSubagentTab[]>
   variants: Accessor<string[]>
   keybinds: FooterKeybinds
   onClose: () => void
   onModel: () => void
+  onSubagent: () => void
   onVariant: () => void
   onVariantCycle: () => void
   onCommand: (name: string) => void
@@ -293,6 +316,19 @@ export function RunCommandMenuBody(props: {
         category: "Suggested",
         display: "Switch model",
       },
+      ...(props.subagents().length > 0
+        ? [
+            {
+              action: "subagent" as const,
+              category: "Suggested",
+              display: "View subagents",
+              footer: `${props.subagents().length} active`,
+              keywords: props.subagents()
+                .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
+                .join(" "),
+            },
+          ]
+        : []),
       {
         action: "variant.cycle",
         category: "Suggested",
@@ -346,6 +382,11 @@ export function RunCommandMenuBody(props: {
       return
     }
 
+    if (item.action === "subagent") {
+      props.onSubagent()
+      return
+    }
+
     if (item.action === "variant.cycle") {
       props.onVariantCycle()
       return
@@ -423,6 +464,101 @@ export function RunCommandMenuBody(props: {
   )
 }
 
+export function RunSubagentSelectBody(props: {
+  theme: Accessor<RunFooterTheme>
+  tabs: Accessor<FooterSubagentTab[]>
+  current: Accessor<string | undefined>
+  onClose: () => void
+  onSelect: (sessionID: string) => void
+  onRows?: (rows: number) => void
+}) {
+  let field: InputRenderable | undefined
+  const [query, setQuery] = createSignal("")
+  const entries = createMemo<SubagentEntry[]>(() =>
+    props.tabs().map((item) => {
+      const title = item.description || item.title || item.label
+      return {
+        category: "",
+        display: title,
+        description: title === item.label ? undefined : item.label,
+        footer: subagentStatusLabel(item.status),
+        keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
+        sessionID: item.sessionID,
+        current: props.current() === item.sessionID,
+      }
+    }),
+  )
+  const items = createMemo<SubagentEntry[]>(() => match(query(), entries()))
+  const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
+  const select = () => {
+    const item = items()[menu.selected()]
+    if (!item) {
+      return
+    }
+
+    props.onSelect(item.sessionID)
+  }
+
+  createEffect(() => {
+    query()
+    menu.reset()
+  })
+
+  createEffect(() => {
+    if (query().trim()) {
+      return
+    }
+
+    const index = items().findIndex((item) => item.current)
+    if (index !== -1) {
+      menu.reveal(index)
+    }
+  })
+
+  createEffect(() => {
+    props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
+  })
+
+  useKeyboard((event) => {
+    if (event.defaultPrevented) {
+      return
+    }
+
+    handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
+  })
+
+  return (
+    <PanelShell
+      id="run-direct-footer-subagent-panel"
+      title="Select subagent"
+      query={query()}
+      count={items().length}
+      total={entries().length}
+      placeholder="Search"
+      theme={props.theme}
+      inputRef={(input) => {
+        field = input
+      }}
+      onQuery={setQuery}
+    >
+      <RunFooterMenu
+        id="run-direct-footer-subagent-list"
+        theme={props.theme}
+        items={items}
+        selected={menu.selected}
+        offset={menu.offset}
+        rows={menu.rows}
+        limit={SUBAGENT_LIST_ROWS}
+        empty="No active subagents"
+        border={false}
+        paddingLeft={PANEL_PAD}
+        paddingRight={PANEL_PAD}
+        grouped={false}
+      />
+    </PanelShell>
+  )
+}
+
 export function RunVariantSelectBody(props: {
   theme: Accessor<RunFooterTheme>
   variants: Accessor<string[]>

+ 25 - 1
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -66,6 +66,7 @@ type PromptInput = {
   directory: string
   findFiles: (query: string) => Promise<string[]>
   agents: Accessor<RunAgent[]>
+  subagents: Accessor<number>
   resources: Accessor<RunResource[]>
   commands: Accessor<RunCommand[] | undefined>
   keybinds: FooterKeybinds
@@ -81,6 +82,7 @@ type PromptInput = {
   onInputClear: () => void
   onExitRequest?: () => boolean
   onExit: () => void
+  onSubagentMenu?: () => void
   onRows: (rows: number) => void
   onStatus: (text: string) => void
 }
@@ -995,6 +997,23 @@ export function createPromptState(input: PromptInput): PromptState {
       }
     }
 
+    if (
+      key.name === "down" &&
+      !visible() &&
+      !event.ctrl &&
+      !event.meta &&
+      !event.shift &&
+      !event.super &&
+      area &&
+      !area.isDestroyed &&
+      area.plainText.length === 0 &&
+      input.subagents() > 0
+    ) {
+      event.preventDefault()
+      input.onSubagentMenu?.()
+      return
+    }
+
     if (promptHit(keys().clear, key)) {
       const handled = requestExit()
       if (handled) {
@@ -1049,7 +1068,12 @@ export function createPromptState(input: PromptInput): PromptState {
       return
     }
 
-    if (input.view() === "command" || input.view() === "model" || input.view() === "variant") {
+    if (
+      input.view() === "command" ||
+      input.view() === "model" ||
+      input.view() === "variant" ||
+      input.view() === "subagent-menu"
+    ) {
       return
     }
 

+ 48 - 68
packages/opencode/src/cli/cmd/run/footer.subagent.tsx

@@ -2,14 +2,13 @@
 import type { ScrollBoxRenderable } from "@opentui/core"
 import { useKeyboard } from "@opentui/solid"
 import "opentui-spinner/solid"
-import { createMemo, indexArray, mapArray } from "solid-js"
+import { Show, createMemo, indexArray } from "solid-js"
 import { SPINNER_FRAMES } from "../tui/component/spinner"
 import { RunEntryContent, separatorRows } 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
+export const SUBAGENT_INSPECTOR_ROWS = 14
 
 function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
   if (status === "completed") {
@@ -35,74 +34,12 @@ function statusIcon(status: FooterSubagentTab["status"]) {
   return "◔"
 }
 
-function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
-  const perTab = Math.max(1, Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)))
-  if (count >= 8 || perTab < 12) {
-    return `[${slot}]`
-  }
-
-  const prefix = `[${slot}]`
-  if (count >= 5 || perTab < 24) {
-    return prefix
-  }
-
-  const label = tab.description || tab.title || tab.label
-  return `${prefix} ${label}`
-}
-
-export function RunFooterSubagentTabs(props: {
-  tabs: FooterSubagentTab[]
-  selected?: string
-  theme: RunFooterTheme
-  width: number
-}) {
-  const items = mapArray(
-    () => props.tabs,
-    (tab, index) => {
-      const active = () => props.selected === tab.sessionID
-      const slot = () => String(index() + 1)
-      return (
-        <box paddingRight={1}>
-          <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={active() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
-              {tabText(tab, slot(), props.tabs.length, props.width)}
-            </text>
-          </box>
-        </box>
-      )
-    },
-  )
-
-  return (
-    <box
-      id="run-direct-footer-subagent-tabs"
-      width="100%"
-      height={SUBAGENT_TAB_ROWS}
-      paddingLeft={1}
-      paddingRight={2}
-      paddingBottom={1}
-      flexDirection="row"
-      flexShrink={0}
-    >
-      <box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>
-        {items()}
-      </box>
-    </box>
-  )
-}
-
 export function RunFooterSubagentBody(props: {
   active: () => boolean
   theme: () => RunTheme
+  tab: () => FooterSubagentTab | undefined
+  index: () => number
+  total: () => number
   detail: () => FooterSubagentDetail | undefined
   width: () => number
   diffStyle?: RunDiffStyle
@@ -111,6 +48,7 @@ export function RunFooterSubagentBody(props: {
 }) {
   const theme = createMemo(() => props.theme())
   const footer = createMemo(() => theme().footer)
+  const tab = createMemo(() => props.tab())
   const commits = createMemo(() => props.detail()?.commits ?? [])
   const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
   const scrollbar = createMemo(() => ({
@@ -119,6 +57,22 @@ export function RunFooterSubagentBody(props: {
       foregroundColor: footer().line,
     },
   }))
+  const title = createMemo(() => {
+    const current = tab()
+    if (!current) {
+      return ""
+    }
+
+    return current.description || current.title || current.label
+  })
+  const subtitle = createMemo(() => {
+    const current = tab()
+    if (!current || title() === current.label) {
+      return ""
+    }
+
+    return current.label
+  })
   const rows = indexArray(commits, (commit, index) => (
     <box flexDirection="column" gap={0} flexShrink={0}>
       {index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
@@ -165,6 +119,32 @@ export function RunFooterSubagentBody(props: {
       backgroundColor={footer().surface}
     >
       <box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
+        <Show when={tab()}>
+          {(current) => (
+            <box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
+              {current().status === "running" ? (
+                <box flexShrink={0}>
+                  <spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(footer(), current().status)} />
+                </box>
+              ) : (
+                <text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
+                  {statusIcon(current().status)}
+                </text>
+              )}
+              <text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
+                {title()}
+                <Show when={subtitle().length > 0}>
+                  <span style={{ fg: footer().muted }}>{"  " + subtitle()}</span>
+                </Show>
+              </text>
+              <Show when={props.total() > 1 && props.index() > 0}>
+                <text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
+                  {props.index()} of {props.total()}
+                </text>
+              </Show>
+            </box>
+          )}
+        </Show>
         <scrollbox
           width="100%"
           height="100%"

+ 13 - 11
packages/opencode/src/cli/cmd/run/footer.ts

@@ -29,8 +29,8 @@ import { render } from "@opentui/solid"
 import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 import { withRunSpan } from "./otel"
-import { RUN_COMMAND_PANEL_ROWS } from "./footer.command"
-import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
+import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
+import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
 import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
 import { printableBinding } from "./prompt.shared"
 import { RunFooterView } from "./footer.view"
@@ -97,6 +97,7 @@ type RunFooterOptions = {
 const PERMISSION_ROWS = 12
 const QUESTION_ROWS = 14
 const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
+const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
 const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
 const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
 const AUTOCOMPLETE_COMPACT_ROWS = 2
@@ -190,7 +191,7 @@ export class RunFooter implements FooterApi {
   private subagent: Accessor<FooterSubagentState>
   private setSubagent: (next: FooterSubagentState) => void
   private promptRoute: FooterPromptRoute = { type: "composer" }
-  private tabsVisible = false
+  private subagentMenuRows = SUBAGENT_ROWS
   private autocomplete = false
   private interruptTimeout: NodeJS.Timeout | undefined
   private exitTimeout: NodeJS.Timeout | undefined
@@ -553,22 +554,23 @@ 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 compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
-    const base = this.base + tabs - compact
+    const base = this.base - compact
     const height =
       type === "permission"
         ? this.base + PERMISSION_ROWS
         : type === "question"
           ? this.base + QUESTION_ROWS
           : this.promptRoute.type === "command"
-            ? 1 + tabs + COMMAND_ROWS
+            ? 1 + COMMAND_ROWS
             : this.promptRoute.type === "model"
-              ? 1 + tabs + MODEL_ROWS
+              ? 1 + MODEL_ROWS
               : this.promptRoute.type === "variant"
-                ? 1 + tabs + VARIANT_ROWS
+                ? 1 + VARIANT_ROWS
+                : this.promptRoute.type === "subagent-menu"
+                  ? 1 + this.subagentMenuRows
                 : this.promptRoute.type === "subagent"
-                  ? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
+                  ? this.base + SUBAGENT_INSPECTOR_ROWS
                   : Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
 
     if (height !== this.renderer.footerHeight) {
@@ -592,10 +594,10 @@ export class RunFooter implements FooterApi {
     }
   }
 
-  private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }): void => {
+  private syncLayout = (next: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }): void => {
     this.promptRoute = next.route
-    this.tabsVisible = next.tabs
     this.autocomplete = next.autocomplete
+    this.subagentMenuRows = next.subagentRows
     if (this.view().type === "prompt") {
       this.applyHeight()
     }

+ 108 - 80
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -14,9 +14,15 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
 import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import "opentui-spinner/solid"
 import { createColors, createFrames } from "../tui/ui/spinner"
-import { RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "./footer.command"
+import {
+  RUN_SUBAGENT_PANEL_ROWS,
+  RunCommandMenuBody,
+  RunModelSelectBody,
+  RunSubagentSelectBody,
+  RunVariantSelectBody,
+} from "./footer.command"
 import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
-import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
+import { RunFooterSubagentBody } from "./footer.subagent"
 import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
 import { RunPermissionBody } from "./footer.permission"
 import { RunQuestionBody } from "./footer.question"
@@ -85,30 +91,11 @@ type RunFooterViewProps = {
   onModelSelect: (model: NonNullable<RunInput["model"]>) => void
   onVariantSelect: (variant: string | undefined) => void
   onRows: (rows: number) => void
-  onLayout: (input: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }) => void
+  onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
   onStatus: (text: string) => void
   onSubagentSelect?: (sessionID: string | undefined) => void
 }
 
-function subagentShortcut(event: {
-  name: string
-  ctrl?: boolean
-  meta?: boolean
-  shift?: boolean
-  super?: boolean
-}): number | undefined {
-  if (!event.ctrl || event.meta || event.super) {
-    return undefined
-  }
-
-  if (!/^[0-9]$/.test(event.name)) {
-    return undefined
-  }
-
-  const slot = Number(event.name)
-  return slot === 0 ? 9 : slot - 1
-}
-
 export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
 
 export function RunFooterView(props: RunFooterViewProps) {
@@ -125,18 +112,39 @@ export function RunFooterView(props: RunFooterViewProps) {
     )
   })
   const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
+  const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
   const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
+  const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
   const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
   const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
   const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
   const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
-  const panel = createMemo(() => commanding() || modeling() || varianting())
+  const panel = createMemo(() => selectingSubagent() || commanding() || modeling() || varianting())
   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 selectedTab = createMemo(() => tabs().find((item) => item.sessionID === selected()))
+  const selectedIndex = createMemo(() => {
+    const sessionID = selected()
+    if (!sessionID) {
+      return 0
+    }
+
+    return tabs().findIndex((item) => item.sessionID === sessionID) + 1
+  })
+  const subagentIndicator = createMemo(() => {
+    const count = tabs().length
+    if (count === 0) {
+      return
+    }
+
+    return {
+      count,
+      label: count === 1 ? "agent" : "agents",
+    }
+  })
   const detail = createMemo(() => {
     const current = route()
     return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
@@ -203,6 +211,15 @@ export function RunFooterView(props: RunFooterViewProps) {
     props.onSubagentSelect?.(undefined)
   }
 
+  const openSubagentMenu = () => {
+    if (tabs().length === 0) {
+      return
+    }
+
+    setRoute({ type: "subagent-menu" })
+    props.onSubagentSelect?.(undefined)
+  }
+
   const closePanel = () => {
     setRoute({ type: "composer" })
   }
@@ -217,16 +234,6 @@ export function RunFooterView(props: RunFooterViewProps) {
     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
@@ -247,6 +254,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     directory: props.directory,
     findFiles: props.findFiles,
     agents: props.agents,
+    subagents: () => tabs().length,
     resources: props.resources,
     commands: props.commands,
     keybinds: props.keybinds,
@@ -262,6 +270,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     onInputClear: props.onInputClear,
     onExitRequest: props.onExitRequest,
     onExit: props.onExit,
+    onSubagentMenu: openSubagentMenu,
     onRows: props.onRows,
     onStatus: props.onStatus,
   })
@@ -301,34 +310,29 @@ export function RunFooterView(props: RunFooterViewProps) {
     openCommand()
   })
 
-  useKeyboard((event) => {
-    if (active().type !== "prompt") {
+  createEffect(() => {
+    const current = route()
+    if (current.type !== "subagent") {
       return
     }
 
-    const slot = subagentShortcut(event)
-    if (slot !== undefined) {
-      const next = tabs()[slot]
-      if (!next) {
-        return
-      }
-
-      event.preventDefault()
-      toggleTab(next.sessionID)
+    if (tabs().some((item) => item.sessionID === current.sessionID)) {
+      return
     }
+
+    closeTab()
   })
 
   createEffect(() => {
-    const current = route()
-    if (current.type !== "subagent") {
+    if (route().type !== "subagent-menu") {
       return
     }
 
-    if (tabs().some((item) => item.sessionID === current.sessionID)) {
+    if (tabs().length > 0) {
       return
     }
 
-    closeTab()
+    closePanel()
   })
 
   createEffect(() => {
@@ -337,7 +341,12 @@ export function RunFooterView(props: RunFooterViewProps) {
     }
 
     const current = route()
-    if (current.type !== "command" && current.type !== "model" && current.type !== "variant") {
+    if (
+      current.type !== "command" &&
+      current.type !== "model" &&
+      current.type !== "variant" &&
+      current.type !== "subagent-menu"
+    ) {
       return
     }
 
@@ -347,8 +356,8 @@ export function RunFooterView(props: RunFooterViewProps) {
   createEffect(() => {
     props.onLayout({
       route: route(),
-      tabs: tabs().length > 0,
       autocomplete: menu(),
+      subagentRows: subagentMenuRows(),
     })
   })
 
@@ -365,10 +374,6 @@ export function RunFooterView(props: RunFooterViewProps) {
     >
       <box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
 
-      <Show when={showTabs()}>
-        <RunFooterSubagentTabs tabs={tabs()} selected={selected()} theme={theme()} width={term().width} />
-      </Show>
-
       <Show
         when={inspecting()}
         fallback={
@@ -409,14 +414,26 @@ export function RunFooterView(props: RunFooterViewProps) {
                         bind={composer.bind}
                       />
                     </Match>
+                    <Match when={selectingSubagent()}>
+                      <RunSubagentSelectBody
+                        theme={theme}
+                        tabs={tabs}
+                        current={selected}
+                        onClose={closePanel}
+                        onSelect={openTab}
+                        onRows={setSubagentMenuRows}
+                      />
+                    </Match>
                     <Match when={commanding()}>
                       <RunCommandMenuBody
                         theme={theme}
                         commands={props.commands}
+                        subagents={tabs}
                         variants={props.variants}
                         keybinds={props.keybinds}
                         onClose={closePanel}
                         onModel={openModel}
+                        onSubagent={openSubagentMenu}
                         onVariant={openVariant}
                         onVariantCycle={() => {
                           props.onCycle()
@@ -573,22 +590,16 @@ export function RunFooterView(props: RunFooterViewProps) {
                     gap={1}
                     flexShrink={0}
                   >
-                    <Show when={busy() || exiting()}>
-                      <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
+                    <Show when={busy() || exiting() || duration().length > 0 || subagentIndicator()}>
+                      <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
                         <Show when={exiting()}>
-                          <text
-                            id="run-direct-footer-hint-exit"
-                            fg={theme().highlight}
-                            wrapMode="none"
-                            truncate
-                            marginLeft={1}
-                          >
+                          <text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate>
                             Press Ctrl-c again to exit
                           </text>
                         </Show>
 
                         <Show when={busy() && !exiting()}>
-                          <box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
+                          <box id="run-direct-footer-status-spinner" flexShrink={0}>
                             <spinner color={spin().color} frames={spin().frames} interval={40} />
                           </box>
 
@@ -604,22 +615,36 @@ export function RunFooterView(props: RunFooterViewProps) {
                             </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>
+                        <Show when={!busy() && !exiting() && duration().length > 0}>
+                          <box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0}>
+                            <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>
+
+                        <Show when={subagentIndicator()}>
+                          {(info) => (
+                            <text id="run-direct-footer-subagents-label" fg={theme().text} wrapMode="none" truncate>
+                              <Show when={busy() || exiting() || duration().length > 0}>
+                                <span style={{ fg: theme().muted }}>· </span>
+                              </Show>
+                              {info().count} <span style={{ fg: theme().muted }}>{info().label}</span>
+                              <span style={{ fg: theme().muted }}> · </span>
+                              <span style={{ fg: theme().highlight }}>↓</span>
+                              <span style={{ fg: theme().muted }}> to view</span>
+                            </text>
+                          )}
+                        </Show>
                       </box>
                     </Show>
 
@@ -720,6 +745,9 @@ export function RunFooterView(props: RunFooterViewProps) {
           <RunFooterSubagentBody
             active={inspecting}
             theme={runTheme}
+            tab={selectedTab}
+            index={selectedIndex}
+            total={() => tabs().length}
             detail={detail}
             width={() => term().width}
             diffStyle={props.diffStyle}

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

@@ -163,6 +163,7 @@ export type FooterView =
 
 export type FooterPromptRoute =
   | { type: "composer" }
+  | { type: "subagent-menu" }
   | { type: "subagent"; sessionID: string }
   | { type: "command" }
   | { type: "model" }

+ 183 - 1
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -4,13 +4,26 @@ import { testRender } from "@opentui/solid"
 import { createSignal } from "solid-js"
 import {
   RUN_COMMAND_PANEL_ROWS,
+  RUN_SUBAGENT_PANEL_ROWS,
   RunCommandMenuBody,
   RunModelSelectBody,
+  RunSubagentSelectBody,
   RunVariantSelectBody,
 } from "@/cli/cmd/run/footer.command"
+import { RunFooterView } from "@/cli/cmd/run/footer.view"
 import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
 import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
-import type { FooterKeybinds, RunCommand, RunInput, RunProvider, StreamCommit } from "@/cli/cmd/run/types"
+import type {
+  FooterKeybinds,
+  FooterState,
+  FooterSubagentState,
+  FooterSubagentTab,
+  FooterView,
+  RunCommand,
+  RunInput,
+  RunProvider,
+  StreamCommit,
+} from "@/cli/cmd/run/types"
 
 function bindings(...keys: string[]) {
   return keys.map((key) => ({ key }))
@@ -111,6 +124,18 @@ function provider() {
   } satisfies RunProvider
 }
 
+function subagent(input: { sessionID: string; label: string; description: string; status?: FooterSubagentTab["status"] }) {
+  return {
+    sessionID: input.sessionID,
+    partID: `part-${input.sessionID}`,
+    callID: `call-${input.sessionID}`,
+    label: input.label,
+    description: input.description,
+    status: input.status ?? "running",
+    lastUpdatedAt: 1,
+  } satisfies FooterSubagentTab
+}
+
 test("run entry content updates when live commit text changes", async () => {
   const [commit, setCommit] = createSignal<StreamCommit>({
     kind: "tool",
@@ -161,6 +186,7 @@ test("direct command panel renders grouped command palette", async () => {
     command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
     command({ name: "internal", description: "Skill command", source: "skill" }),
   ])
+  const [subagents] = createSignal([])
   const [variants] = createSignal(["high", "minimal"])
 
   const app = await testRender(
@@ -169,10 +195,12 @@ test("direct command panel renders grouped command palette", async () => {
         <RunCommandMenuBody
           theme={() => RUN_THEME_FALLBACK.footer}
           commands={commands}
+          subagents={subagents}
           variants={variants}
           keybinds={keybinds}
           onClose={() => {}}
           onModel={() => {}}
+          onSubagent={() => {}}
           onVariant={() => {}}
           onVariantCycle={() => {}}
           onCommand={() => {}}
@@ -214,6 +242,160 @@ test("direct command panel renders grouped command palette", async () => {
   }
 })
 
+test("direct command panel shows subagent entry when available", async () => {
+  const [commands] = createSignal<RunCommand[] | undefined>([])
+  const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
+  const [variants] = createSignal<string[]>([])
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
+        <RunCommandMenuBody
+          theme={() => RUN_THEME_FALLBACK.footer}
+          commands={commands}
+          subagents={subagents}
+          variants={variants}
+          keybinds={keybinds}
+          onClose={() => {}}
+          onModel={() => {}}
+          onSubagent={() => {}}
+          onVariant={() => {}}
+          onVariantCycle={() => {}}
+          onCommand={() => {}}
+          onNew={() => {}}
+          onExit={() => {}}
+        />
+      </box>
+    ),
+    {
+      width: 100,
+      height: RUN_COMMAND_PANEL_ROWS,
+    },
+  )
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("View subagents")
+    expect(frame).toContain("1 active")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("direct subagent panel renders active subagents", async () => {
+  const [tabs] = createSignal([
+    subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
+    subagent({ sessionID: "s-2", label: "General", description: "Write migration plan", status: "completed" }),
+  ])
+  const [current] = createSignal<string | undefined>("s-1")
+  let rows = 0
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
+        <RunSubagentSelectBody
+          theme={() => RUN_THEME_FALLBACK.footer}
+          tabs={tabs}
+          current={current}
+          onClose={() => {}}
+          onSelect={() => {}}
+          onRows={(value) => {
+            rows = value
+          }}
+        />
+      </box>
+    ),
+    {
+      width: 100,
+      height: RUN_SUBAGENT_PANEL_ROWS,
+    },
+  )
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("Select subagent")
+    expect(frame).toContain("Inspect auth flow")
+    expect(frame).toContain("Write migration plan")
+    expect(frame).toContain("done")
+    expect(rows).toBe(8)
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("direct footer shows subagent indicator while prompt is running", async () => {
+  const [state] = createSignal<FooterState>({
+    phase: "running",
+    status: "",
+    queue: 0,
+    model: "gpt-5",
+    duration: "",
+    usage: "",
+    first: false,
+    interrupt: 0,
+    exit: 0,
+  })
+  const [view] = createSignal<FooterView>({ type: "prompt" })
+  const [subagents] = createSignal<FooterSubagentState>({
+    tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
+    details: {},
+    permissions: [],
+    questions: [],
+  })
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={8}>
+        <RunFooterView
+          directory="/tmp"
+          findFiles={async () => []}
+          agents={() => []}
+          resources={() => []}
+          commands={() => []}
+          providers={() => undefined}
+          currentModel={() => undefined}
+          variants={() => []}
+          currentVariant={() => undefined}
+          state={state}
+          view={view}
+          subagent={subagents}
+          theme={RUN_THEME_FALLBACK}
+          keybinds={keybinds}
+          agent="opencode"
+          onSubmit={() => true}
+          onPermissionReply={() => {}}
+          onQuestionReply={() => {}}
+          onQuestionReject={() => {}}
+          onCycle={() => {}}
+          onInterrupt={() => false}
+          onInputClear={() => {}}
+          onExit={() => {}}
+          onModelSelect={() => {}}
+          onVariantSelect={() => {}}
+          onRows={() => {}}
+          onLayout={() => {}}
+          onStatus={() => {}}
+        />
+      </box>
+    ),
+    {
+      width: 100,
+      height: 8,
+    },
+  )
+
+  try {
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("direct model panel renders current model selector", async () => {
   const [providers] = createSignal<RunProvider[] | undefined>([provider()])
   const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })