From ed839846d1a9f74b7084c46aa304333f2e665673 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 20 May 2026 21:07:35 +0200 Subject: [PATCH] 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. --- .../src/cli/cmd/run/footer.command.tsx | 140 ++++++++++++- .../src/cli/cmd/run/footer.prompt.tsx | 26 ++- .../src/cli/cmd/run/footer.subagent.tsx | 116 +++++------ packages/opencode/src/cli/cmd/run/footer.ts | 24 ++- .../opencode/src/cli/cmd/run/footer.view.tsx | 192 ++++++++++-------- packages/opencode/src/cli/cmd/run/types.ts | 1 + .../test/cli/run/footer.view.test.tsx | 184 ++++++++++++++++- 7 files changed, 518 insertions(+), 165 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index 4da370eabe..9002808647 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/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 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 commands: Accessor + subagents: Accessor variants: Accessor 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 + tabs: Accessor + current: Accessor + onClose: () => void + onSelect: (sessionID: string) => void + onRows?: (rows: number) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + 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(() => 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 ( + { + field = input + }} + onQuery={setQuery} + > + + + ) +} + export function RunVariantSelectBody(props: { theme: Accessor variants: Accessor diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 0caae36d0e..c3f9918acc 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -66,6 +66,7 @@ type PromptInput = { directory: string findFiles: (query: string) => Promise agents: Accessor + subagents: Accessor resources: Accessor commands: Accessor 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 } diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx index 0cd1367249..b3a85ec4a5 100644 --- a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx +++ b/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 ( - - - {tab.status === "running" ? ( - - - - ) : ( - - {statusIcon(tab.status)} - - )} - - {tabText(tab, slot(), props.tabs.length, props.width)} - - - - ) - }, - ) - - return ( - - - {items()} - - - ) -} - 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) => ( {index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? : null} @@ -165,6 +119,32 @@ export function RunFooterSubagentBody(props: { backgroundColor={footer().surface} > + + {(current) => ( + + {current().status === "running" ? ( + + + + ) : ( + + {statusIcon(current().status)} + + )} + + {title()} + 0}> + {" " + subtitle()} + + + 1 && props.index() > 0}> + + {props.index()} of {props.total()} + + + + )} + 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() } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index bc0a3490b1..affc664b15 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/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) => 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({ 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,23 +310,6 @@ export function RunFooterView(props: RunFooterViewProps) { openCommand() }) - useKeyboard((event) => { - if (active().type !== "prompt") { - return - } - - const slot = subagentShortcut(event) - if (slot !== undefined) { - const next = tabs()[slot] - if (!next) { - return - } - - event.preventDefault() - toggleTab(next.sessionID) - } - }) - createEffect(() => { const current = route() if (current.type !== "subagent") { @@ -331,13 +323,30 @@ export function RunFooterView(props: RunFooterViewProps) { closeTab() }) + createEffect(() => { + if (route().type !== "subagent-menu") { + return + } + + if (tabs().length > 0) { + return + } + + closePanel() + }) + createEffect(() => { if (active().type === "prompt") { return } 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) { > - - - - + + + { props.onCycle() @@ -573,22 +590,16 @@ export function RunFooterView(props: RunFooterViewProps) { gap={1} flexShrink={0} > - - + 0 || subagentIndicator()}> + - + Press Ctrl-c again to exit - + @@ -604,22 +615,36 @@ export function RunFooterView(props: RunFooterViewProps) { - - - 0}> - - - ▣ - - - - · - - - {duration()} - - + 0}> + + + ▣ + + + + · + + + {duration()} + + + + + + + {(info) => ( + + 0}> + · + + {info().count} {info().label} + · + + to view + + )} + @@ -720,6 +745,9 @@ export function RunFooterView(props: RunFooterViewProps) { tabs().length} detail={detail} width={() => term().width} diffStyle={props.diffStyle} diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index d16c9bc3bf..d2de3fca8a 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/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" } diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 06f5d93cae..1c4424c36c 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/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({ 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 () => { 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([]) + const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })]) + const [variants] = createSignal([]) + + const app = await testRender( + () => ( + + RUN_THEME_FALLBACK.footer} + commands={commands} + subagents={subagents} + variants={variants} + keybinds={keybinds} + onClose={() => {}} + onModel={() => {}} + onSubagent={() => {}} + onVariant={() => {}} + onVariantCycle={() => {}} + onCommand={() => {}} + onNew={() => {}} + onExit={() => {}} + /> + + ), + { + 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("s-1") + let rows = 0 + + const app = await testRender( + () => ( + + RUN_THEME_FALLBACK.footer} + tabs={tabs} + current={current} + onClose={() => {}} + onSelect={() => {}} + onRows={(value) => { + rows = value + }} + /> + + ), + { + 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({ + phase: "running", + status: "", + queue: 0, + model: "gpt-5", + duration: "", + usage: "", + first: false, + interrupt: 0, + exit: 0, + }) + const [view] = createSignal({ type: "prompt" }) + const [subagents] = createSignal({ + tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })], + details: {}, + permissions: [], + questions: [], + }) + + const app = await testRender( + () => ( + + []} + 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={() => {}} + /> + + ), + { + 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([provider()]) const [current] = createSignal({ providerID: "opencode", modelID: "gpt-5" })