Bladeren bron

feat(tui): add mini agent switching (#38287)

Fixes #36121
Simon Klee 3 weken geleden
bovenliggende
commit
a3e2cc0dcd

+ 1 - 1
packages/cli/src/session-target.ts

@@ -74,7 +74,7 @@ export async function resolveSessionTarget(input: {
     session,
     location,
     model: prepared.model,
-    agent: prepared.agent,
+    agent: prepared.agent ?? session.agent,
     resume: selected !== undefined,
   }
 }

+ 9 - 0
packages/cli/test/session-target.test.ts

@@ -78,6 +78,15 @@ describe("session target resolver", () => {
     expect(order).toEqual(["prepare", "create"])
   })
 
+  test("uses the agent resolved by the server for a fresh Session", async () => {
+    const client = OpenCode.make({ baseUrl: "https://opencode.test" })
+    spyOn(client.location, "get").mockResolvedValue(location("/project"))
+    spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
+
+    const target = await resolveSessionTarget({ client, prepare })
+    expect(target.agent).toBe("review")
+  })
+
   test("does not retry an ambiguous Session creation", async () => {
     const client = OpenCode.make({ baseUrl: "https://opencode.test" })
     spyOn(client.location, "get").mockResolvedValue(location("/project"))

+ 1 - 0
packages/tui/src/mini/catalog.shared.ts

@@ -34,6 +34,7 @@ function runAgent(input: CurrentAgent): RunAgent {
   return {
     id: input.id,
     name: input.name,
+    description: input.description,
     mode: input.mode,
     hidden: input.hidden,
   }

+ 79 - 0
packages/tui/src/mini/footer.command.tsx

@@ -10,6 +10,7 @@ import type {
   FooterSubagentTab,
   MiniSettingChange,
   MiniSettings,
+  RunAgent,
   RunCommand,
   RunInput,
   RunProvider,
@@ -21,6 +22,7 @@ type PanelEntry = RunFooterMenuItem & {
 }
 
 type CommandEntry =
+  | (PanelEntry & { action: "agent" })
   | (PanelEntry & { action: "model" })
   | (PanelEntry & { action: "editor" })
   | (PanelEntry & { action: "skill" })
@@ -40,6 +42,11 @@ type ModelEntry = PanelEntry & {
   current: boolean
 }
 
+type AgentEntry = PanelEntry & {
+  id: string
+  current: boolean
+}
+
 type VariantEntry = PanelEntry & {
   variant: string | undefined
   current: boolean
@@ -341,6 +348,7 @@ export function RunCommandMenuBody(props: {
   variants: Accessor<string[]>
   variantCycle: string
   onClose: () => void
+  onAgent: () => void
   onModel: () => void
   onEditor: () => void
   onSkill: () => void
@@ -419,6 +427,11 @@ export function RunCommandMenuBody(props: {
         ]
         : []
     const agent: CommandEntry[] = [
+      {
+        action: "agent",
+        category: "Agent",
+        display: "Switch agent",
+      },
       {
         action: "model",
         category: "Agent",
@@ -471,6 +484,11 @@ export function RunCommandMenuBody(props: {
     ]
   })
   const pick = (item: CommandEntry) => {
+    if (item.action === "agent") {
+      props.onAgent()
+      return
+    }
+
     if (item.action === "model") {
       props.onModel()
       return
@@ -568,6 +586,67 @@ export function RunCommandMenuBody(props: {
   )
 }
 
+export function RunAgentSelectBody(props: {
+  theme: Accessor<RunFooterTheme>
+  agents: Accessor<RunAgent[]>
+  current: Accessor<string | undefined>
+  onClose: () => void
+  onSelect: (agent: string) => void
+  mono?: boolean
+}) {
+  const entries = createMemo<AgentEntry[]>(() =>
+    props
+      .agents()
+      .filter((agent) => agent.mode !== "subagent" && !agent.hidden)
+      .map((agent) => ({
+        category: "",
+        display: agent.id,
+        description: agent.description,
+        footer: props.current() === agent.id ? "current" : undefined,
+        keywords: `${agent.id} ${agent.name} ${agent.description ?? ""}`,
+        id: agent.id,
+        current: props.current() === agent.id,
+      })),
+  )
+  const controller = createSearchablePanelController({
+    entries,
+    limit: PANEL_LIST_ROWS,
+    onClose: props.onClose,
+    onSelect: (item) => props.onSelect(item.id),
+    isCurrent: (item) => item.current,
+  })
+
+  return (
+    <PanelShell
+      title="Select agent"
+      query={controller.query()}
+      count={controller.items().length}
+      total={entries().length}
+      placeholder="Search"
+      theme={props.theme}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
+      mono={props.mono}
+    >
+      <RunFooterMenu
+        theme={props.theme}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
+        rows={() => PANEL_LIST_ROWS}
+        limit={PANEL_LIST_ROWS}
+        empty="No agents found"
+        border={false}
+        paddingLeft={panelPad(props.mono)}
+        paddingRight={panelPad(props.mono)}
+        grouped={false}
+        background
+        mono={props.mono}
+      />
+    </PanelShell>
+  )
+}
+
 export function RunSettingsBody(props: {
   theme: Accessor<RunFooterTheme>
   settings: Accessor<MiniSettings>

+ 30 - 4
packages/tui/src/mini/footer.ts

@@ -74,7 +74,7 @@ type RunFooterOptions = {
   agents: RunAgent[]
   references: RunReference[]
   wrote?: boolean
-  agentLabel: string
+  agent: string | undefined
   modelLabel: string
   model: RunInput["model"]
   variant: string | undefined
@@ -91,6 +91,7 @@ type RunFooterOptions = {
   onFormReply: (input: FormReply) => void | Promise<void>
   onFormCancel: (input: FormCancel) => void | Promise<void>
   onCycleVariant?: () => CycleResult | void
+  onAgentSelect?: (agent: string) => void
   onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
   onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
   onInterrupt?: () => void
@@ -169,6 +170,9 @@ export class RunFooter implements FooterApi {
   private setCommands: Setter<RunCommand[] | undefined>
   private providers: Accessor<RunProvider[] | undefined>
   private setProviders: Setter<RunProvider[] | undefined>
+  private currentAgent: Accessor<string>
+  private currentAgentID: Accessor<string | undefined>
+  private setCurrentAgentID: Setter<string | undefined>
   private currentModel: Accessor<RunInput["model"]>
   private setCurrentModel: Setter<RunInput["model"]>
   private variants: Accessor<string[]>
@@ -194,6 +198,7 @@ export class RunFooter implements FooterApi {
   private interruptTimeout: NodeJS.Timeout | undefined
   private exitTimeout: NodeJS.Timeout | undefined
   private noticeTimeout: NodeJS.Timeout | undefined
+  private turnAgent: string | undefined
   private requestExitHandler: (() => boolean) | undefined
   private scrollback: RunScrollbackStream
   private themes: RunTheme[]
@@ -247,6 +252,14 @@ export class RunFooter implements FooterApi {
     const [providers, setProviders] = createSignal<RunProvider[] | undefined>()
     this.providers = providers
     this.setProviders = setProviders
+    const [currentAgentID, setCurrentAgentID] = createSignal(options.agent)
+    this.currentAgentID = currentAgentID
+    this.setCurrentAgentID = setCurrentAgentID
+    this.currentAgent = () => {
+      const agent = currentAgentID()
+      if (!agent) return "Default"
+      return this.agents().find((item) => item.id === agent)?.name ?? Locale.titlecase(agent)
+    }
     const [currentModel, setCurrentModel] = createSignal<RunInput["model"]>(options.model)
     this.currentModel = currentModel
     this.setCurrentModel = setCurrentModel
@@ -305,6 +318,8 @@ export class RunFooter implements FooterApi {
               references: footer.references,
               commands: footer.commands,
               providers: footer.providers,
+              currentAgent: footer.currentAgent,
+              currentAgentID: footer.currentAgentID,
               currentModel: footer.currentModel,
               variants: footer.variants,
               currentVariant: footer.currentVariant,
@@ -324,6 +339,7 @@ export class RunFooter implements FooterApi {
               onExitRequest: footer.handleExit,
               onRequestExit: footer.setRequestExitHandler,
               onExit: () => footer.close(),
+              onAgentSelect: footer.handleAgentSelect,
               onModelSelect: footer.handleModelSelect,
               onVariantSelect: footer.handleVariantSelect,
               onRows: footer.syncRows,
@@ -377,7 +393,7 @@ export class RunFooter implements FooterApi {
     }
 
     if (next.type === "agent") {
-      this.options.agentLabel = Locale.titlecase(next.agent ?? "build")
+      this.setCurrentAgentID(next.agent)
       return
     }
 
@@ -386,13 +402,15 @@ export class RunFooter implements FooterApi {
     }
 
     if (next.type === "turn.duration") {
+      const agent = this.turnAgent ?? this.currentAgent()
+      this.turnAgent = undefined
       if (this.miniSettings().turn_summary === "hide") return
       const current = this.currentModel()
       this.flush()
       this.flushing = this.flushing
         .then(() =>
           this.scrollback.writeTurnSummary({
-            agent: this.options.agentLabel,
+            agent,
             model: current ? modelInfo(this.providers(), current).model : this.state().model,
             duration: next.duration,
           }),
@@ -451,6 +469,7 @@ export class RunFooter implements FooterApi {
         patch.notice = ""
       }
       if (next.type === "turn.send") {
+        this.turnAgent = this.currentAgent()
         this.clearInterruptTimer()
         this.clearExitTimer()
       }
@@ -667,7 +686,7 @@ export class RunFooter implements FooterApi {
         ? this.base + PERMISSION_ROWS
         : type === "form"
           ? this.base + FORM_ROWS
-          : ["command", "skill", "model", "variant", "settings"].includes(route)
+          : ["command", "skill", "agent", "model", "variant", "settings"].includes(route)
             ? 1 + RUN_COMMAND_PANEL_ROWS
             : route === "queued-menu" || route === "subagent-menu"
               ? 1 + this.subagentMenuRows
@@ -815,6 +834,13 @@ export class RunFooter implements FooterApi {
       .catch(() => {})
   }
 
+  private handleAgentSelect = (agent: string): void => {
+    if (this.isClosed || this.currentAgentID() === agent) return
+    this.setCurrentAgentID(agent)
+    this.options.onAgentSelect?.(agent)
+    this.setNotice(`agent ${this.currentAgent()}`)
+  }
+
   private handleVariantSelect = (variant: string | undefined): void => {
     if (this.isClosed) {
       return

+ 33 - 2
packages/tui/src/mini/footer.view.tsx

@@ -14,6 +14,7 @@ import { registerOpencodeSpinner } from "../component/register-spinner"
 import { createColors, createFrames } from "../ui/spinner"
 import {
   RUN_SUBAGENT_PANEL_ROWS,
+  RunAgentSelectBody,
   RunCommandMenuBody,
   RunModelSelectBody,
   RunQueuedPromptSelectBody,
@@ -76,6 +77,8 @@ type RunFooterViewProps = {
   references: () => RunReference[]
   commands: () => RunCommand[] | undefined
   providers: () => RunProvider[] | undefined
+  currentAgent: () => string
+  currentAgentID: () => string | undefined
   currentModel: () => RunInput["model"]
   variants: () => string[]
   currentVariant: () => string | undefined
@@ -99,6 +102,7 @@ type RunFooterViewProps = {
   onExitRequest?: () => boolean
   onRequestExit?: (fn: (() => boolean) | undefined) => void
   onExit: () => void
+  onAgentSelect: (agent: string) => void
   onModelSelect: (model: NonNullable<RunInput["model"]>) => void
   onVariantSelect: (variant: string | undefined) => void
   onRows: (rows: number) => void
@@ -134,6 +138,7 @@ export function RunFooterView(props: RunFooterViewProps) {
   const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
   const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
   const skilling = createMemo(() => active().type === "prompt" && route().type === "skill")
+  const agenting = createMemo(() => active().type === "prompt" && route().type === "agent")
   const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
   const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
   const setting = createMemo(() => active().type === "prompt" && route().type === "settings")
@@ -145,6 +150,7 @@ export function RunFooterView(props: RunFooterViewProps) {
       selectingSubagent() ||
       commanding() ||
       skilling() ||
+      agenting() ||
       modeling() ||
       varianting() ||
       setting(),
@@ -219,7 +225,7 @@ export function RunFooterView(props: RunFooterViewProps) {
   const footerStatus = createMemo(() => {
     const current = model() ?? props.state().model.trim()
     const variant = props.currentVariant()
-    const details = [busy() ? "running" : "idle"]
+    const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
     if (current) details.push(variant ? `${current} ${variant}` : current)
     if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
     if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
@@ -268,6 +274,11 @@ export function RunFooterView(props: RunFooterViewProps) {
     props.onSubagentSelect?.(undefined)
   }
 
+  const openAgent = () => {
+    setRoute({ type: "agent" })
+    props.onSubagentSelect?.(undefined)
+  }
+
   const openSkillMenu = () => {
     if (props.commands() && skills().length === 0) {
       return
@@ -407,8 +418,9 @@ export function RunFooterView(props: RunFooterViewProps) {
   })
   const modelStatus = createMemo(() => {
     const current = model() ?? props.state().model.trim()
-    if (!footerDetails() || !prompt() || !responsive().statusline.showModel || !current) return
+    if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showModel || !current) return
     return {
+      agent: props.currentAgent(),
       model: current,
       variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined,
     }
@@ -593,6 +605,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     if (
       current.type !== "command" &&
       current.type !== "skill" &&
+      current.type !== "agent" &&
       current.type !== "model" &&
       current.type !== "variant" &&
       current.type !== "settings" &&
@@ -698,6 +711,7 @@ export function RunFooterView(props: RunFooterViewProps) {
                             variants={props.variants}
                             variantCycle={variantCycle()}
                             onClose={closePanel}
+                            onAgent={openAgent}
                             onModel={openModel}
                             onEditor={() => {
                               closePanel()
@@ -748,6 +762,19 @@ export function RunFooterView(props: RunFooterViewProps) {
                             mono={props.mono}
                           />
                         </Match>
+                        <Match when={agenting()}>
+                          <RunAgentSelectBody
+                            theme={theme}
+                            agents={props.agents}
+                            current={props.currentAgentID}
+                            onClose={closePanel}
+                            onSelect={(agent) => {
+                              props.onAgentSelect(agent)
+                              closePanel()
+                            }}
+                            mono={props.mono}
+                          />
+                        </Match>
                         <Match when={modeling()}>
                           <RunModelSelectBody
                             theme={theme}
@@ -907,6 +934,10 @@ export function RunFooterView(props: RunFooterViewProps) {
                       flexShrink={1}
                     >
                       <text fg={theme().text} wrapMode="none" truncate>
+                        <Show when={responsive().statusline.showAgent}>
+                          {info().agent}
+                          <span style={{ fg: theme().muted }}>{props.mono ? " - " : " · "}</span>
+                        </Show>
                         {info().model}
                         <Show when={info().variant}>
                           {(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}

+ 1 - 0
packages/tui/src/mini/footer.width.ts

@@ -20,6 +20,7 @@ export function footerWidthPolicy(width: number) {
     },
     statusline: {
       showActivityMeta: compact,
+      showAgent: compact,
       showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
       showModel: width >= FOOTER_WIDTH_BREAKPOINTS.model,
       showModelVariant: width >= FOOTER_WIDTH_BREAKPOINTS.modelVariant,

+ 4 - 20
packages/tui/src/mini/runtime.lifecycle.ts

@@ -11,7 +11,6 @@
 import path from "path"
 import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
 import { isDefaultTitle } from "../util/session"
-import { Locale } from "../util/locale"
 import { entrySplash, exitSplash, splashMeta } from "./splash"
 import { resolveRunTheme } from "./theme"
 import type {
@@ -45,11 +44,6 @@ type CycleResult = {
   variants?: string[]
 }
 
-type FooterLabels = {
-  agentLabel: string
-  modelLabel: string
-}
-
 export type LifecycleInput = {
   host: MiniHost
   getDirectory: () => string
@@ -70,6 +64,7 @@ export type LifecycleInput = {
   onFormReply: (input: FormReply) => void | Promise<void>
   onFormCancel: (input: FormCancel) => void | Promise<void>
   onCycleVariant?: () => CycleResult | void
+  onAgentSelect?: (agent: string) => void
   onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
   onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
   onInterrupt?: () => void
@@ -123,14 +118,6 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) {
   }
 }
 
-function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
-  const agentLabel = Locale.titlecase(input.agent ?? "build")
-  return {
-    agentLabel,
-    modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "Default model",
-  }
-}
-
 function directoryLabel(directory: string, home: string) {
   const resolved = path.resolve(directory)
   const display =
@@ -203,11 +190,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     session_id: input.sessionID,
     mono,
   })
-  const labels = footerLabels({
-    agent: input.agent,
-    model: input.model,
-    variant: input.variant,
-  })
   const wrote = queueSplash(
     renderer,
     state,
@@ -232,7 +214,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     findFiles: input.findFiles,
     agents: input.agents,
     references: input.references,
-    ...labels,
+    agent: input.agent,
+    modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "Default model",
     model: input.model,
     variant: input.variant,
     first: input.first,
@@ -249,6 +232,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     onFormReply: input.onFormReply,
     onFormCancel: input.onFormCancel,
     onCycleVariant: input.onCycleVariant,
+    onAgentSelect: input.onAgentSelect,
     onModelSelect: input.onModelSelect,
     onVariantSelect: input.onVariantSelect,
     onInterrupt: input.onInterrupt,

+ 3 - 0
packages/tui/src/mini/runtime.ts

@@ -304,6 +304,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
         variant: state.activeVariant,
       }
     },
+    onAgentSelect: (agent) => {
+      state.agent = agent
+    },
     onModelSelect: async (model) => {
       if (state.model?.providerID === model.providerID && state.model.modelID === model.modelID) {
         return

+ 4 - 0
packages/tui/src/mini/stream-v2.transport.ts

@@ -1553,6 +1553,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         throw new Error("This prompt cannot be queued")
       if (!state.connected) throw new Error("Event stream is reconnecting")
       const client = sdk
+      if (next.agent)
+        await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
       mergePending(await admitPrompt(next, client, "queue"))
       settlementClient = client
     },
@@ -1574,6 +1576,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
 
       const command = next.prompt.command
       if (command?.source === "skill") {
+        if (next.agent)
+          await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
         input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
         await runTurnWait(
           next,

+ 2 - 0
packages/tui/src/mini/types.ts

@@ -94,6 +94,7 @@ export type FooterQueuedPrompt = {
 export type RunAgent = {
   id: string
   name: string
+  description?: string
   mode: "subagent" | "primary" | "all"
   hidden: boolean
 }
@@ -286,6 +287,7 @@ export type FooterPromptRoute =
   | { type: "subagent"; sessionID: string }
   | { type: "command" }
   | { type: "skill" }
+  | { type: "agent" }
   | { type: "model" }
   | { type: "variant" }
   | { type: "settings" }

+ 3 - 0
packages/tui/test/mini/footer-keymap.test.tsx

@@ -47,6 +47,8 @@ test("down opens subagents from an empty prompt", async () => {
           references={() => []}
           commands={() => []}
           providers={() => undefined}
+          currentAgent={() => "Build"}
+          currentAgentID={() => "build"}
           currentModel={() => undefined}
           variants={() => []}
           currentVariant={() => undefined}
@@ -65,6 +67,7 @@ test("down opens subagents from an empty prompt", async () => {
           onEditorOpen={async () => undefined}
           onInputClear={() => {}}
           onExit={() => {}}
+          onAgentSelect={() => {}}
           onModelSelect={() => {}}
           onVariantSelect={() => {}}
           onRows={() => {}}

+ 82 - 4
packages/tui/test/mini/footer.view.test.tsx

@@ -8,6 +8,7 @@ import { Keymap } from "../../src/context/keymap"
 import {
   RUN_COMMAND_PANEL_ROWS,
   RUN_SUBAGENT_PANEL_ROWS,
+  RunAgentSelectBody,
   RunCommandMenuBody,
   RunModelSelectBody,
   RunQueuedPromptSelectBody,
@@ -26,6 +27,7 @@ import type {
   FooterView,
   MiniSettingChange,
   MiniSettings,
+  RunAgent,
   RunCommand,
   RunInput,
   RunPrompt,
@@ -110,6 +112,7 @@ async function renderFooter(
     commands?: RunCommand[]
     theme?: () => RunTheme
     providers?: RunProvider[]
+    currentAgent?: string
     currentModel?: RunInput["model"]
     currentVariant?: string
     subagents?: FooterSubagentState
@@ -122,6 +125,7 @@ async function renderFooter(
     onFormReply?: (input: unknown) => void
     miniSettings?: MiniSettings
     mono?: boolean
+    onStatus?: (status: string) => void
     onMiniSettingChange?: (change: MiniSettingChange) => void
   } = {},
 ) {
@@ -144,6 +148,8 @@ async function renderFooter(
           references={() => []}
           commands={() => input.commands ?? []}
           providers={() => input.providers}
+          currentAgent={() => input.currentAgent ?? "Build"}
+          currentAgentID={() => input.currentAgent?.toLowerCase() ?? "build"}
           currentModel={() => input.currentModel}
           variants={() => []}
           currentVariant={() => input.currentVariant}
@@ -162,11 +168,12 @@ async function renderFooter(
           onEditorOpen={async () => undefined}
           onInputClear={() => {}}
           onExit={() => {}}
+          onAgentSelect={() => {}}
           onModelSelect={() => {}}
           onVariantSelect={() => {}}
           onRows={() => {}}
           onLayout={() => {}}
-          onStatus={() => {}}
+          onStatus={(status) => input.onStatus?.(status)}
           onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)}
         />
       </Keymap.Provider>
@@ -385,6 +392,7 @@ test("direct command panel renders grouped actions without catalog commands", as
           variants={variants}
           variantCycle="ctrl+t"
           onClose={() => {}}
+          onAgent={() => {}}
           onModel={() => {}}
           onEditor={() => {}}
           onSkill={() => {}}
@@ -432,6 +440,11 @@ test("direct command panel renders grouped actions without catalog commands", as
     expect(frame).not.toContain("Review code")
     expect(frame).not.toContain("Commands 8")
 
+    await app.mockInput.typeText("agent")
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("Switch agent")
+
+    app.mockInput.pressKey("u", { ctrl: true })
     await app.mockInput.typeText("review")
     await app.renderOnce()
     expect(app.captureCharFrame()).toContain("No results found")
@@ -615,6 +628,7 @@ test("direct command panel shows subagent entry when available", async () => {
           variants={variants}
           variantCycle="ctrl+t"
           onClose={() => {}}
+          onAgent={() => {}}
           onModel={() => {}}
           onEditor={() => {}}
           onSkill={() => {}}
@@ -665,6 +679,7 @@ test("direct command panel keeps completed subagents available", async () => {
           variants={variants}
           variantCycle="ctrl+t"
           onClose={() => {}}
+          onAgent={() => {}}
           onModel={() => {}}
           onEditor={() => {}}
           onSkill={() => {}}
@@ -1134,6 +1149,8 @@ test("direct footer shows authoritative pending work while running", async () =>
           references={() => []}
           commands={() => []}
           providers={() => undefined}
+          currentAgent={() => "Build"}
+          currentAgentID={() => "build"}
           currentModel={() => ({
             providerID: "opencode",
             modelID: "a-model-name-long-enough-to-force-responsive-truncation",
@@ -1163,6 +1180,7 @@ test("direct footer shows authoritative pending work while running", async () =>
           onEditorOpen={async () => undefined}
           onInputClear={() => {}}
           onExit={() => {}}
+          onAgentSelect={() => {}}
           onModelSelect={() => {}}
           onVariantSelect={() => {}}
           onRows={() => {}}
@@ -1221,12 +1239,14 @@ test("direct footer shows authoritative pending work while running", async () =>
 
 test("direct footer progressively adds model details after the command hint", async () => {
   for (const expected of [
-    { width: 24, model: false, variant: false },
-    { width: 32, model: true, variant: false },
-    { width: 40, model: true, variant: true },
+    { width: 24, agent: false, model: false, variant: false },
+    { width: 32, agent: false, model: true, variant: false },
+    { width: 40, agent: false, model: true, variant: true },
+    { width: 80, agent: true, model: true, variant: true },
   ]) {
     const app = await renderFooter({
       providers: [provider()],
+      currentAgent: "Plan",
       currentModel: { providerID: "opencode", modelID: "gpt-5" },
       currentVariant: "xhigh",
       width: expected.width,
@@ -1238,6 +1258,7 @@ test("direct footer progressively adds model details after the command hint", as
       expect({
         width: expected.width,
         command: frame.includes("ctrl+p cmd"),
+        agent: frame.includes("Plan"),
         model: frame.includes("GPT-5"),
         variant: frame.includes("xhigh"),
       }).toEqual({ ...expected, command: true })
@@ -1327,8 +1348,10 @@ test("direct footer shows full usage metadata when room is available", async ()
 })
 
 test("direct footer hides routine activity and shows explicit notices", async () => {
+  let status = ""
   const app = await renderFooter({
     state: { usage: "159.6K (16%) · $4.23" },
+    currentAgent: "Plan",
     miniSettings: {
       thinking: "hide",
       shell_output: "hide",
@@ -1337,6 +1360,7 @@ test("direct footer hides routine activity and shows explicit notices", async ()
       mono: true,
     },
     mono: true,
+    onStatus: (value) => (status = value),
     width: 160,
   })
 
@@ -1344,6 +1368,7 @@ test("direct footer hides routine activity and shows explicit notices", async ()
     await app.renderOnce()
     const initial = app.captureCharFrame()
     expect(initial).toContain("ctrl+p cmd")
+    expect(initial).not.toContain("Plan")
     expect(initial).not.toContain("gpt-5")
     expect(initial).not.toContain("159.6K")
 
@@ -1359,6 +1384,12 @@ test("direct footer hides routine activity and shows explicit notices", async ()
     expect(changed).not.toContain("159.6K")
     expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined()
 
+    app.mockInput.pressKey("p", { ctrl: true })
+    await app.renderOnce()
+    await app.mockInput.typeText("status")
+    app.mockInput.pressEnter()
+    expect(status).toBe("running - agent Plan - gpt-5 - 159.6K (16%) - $4.23")
+
     app.setState((state) => ({ ...state, notice: "variant high" }))
     await app.renderOnce()
     expect(app.captureCharFrame()).toContain("variant high")
@@ -1473,6 +1504,53 @@ test("direct model panel renders current model selector", async () => {
   }
 })
 
+test("direct agent panel shows eligible agents and marks the current agent", async () => {
+  const [agents] = createSignal<RunAgent[]>([
+    { id: "build", name: "Build", description: "Build software", mode: "all", hidden: false },
+    { id: "review", name: "Review", description: "Review changes", mode: "primary", hidden: false },
+    { id: "explore", name: "Explore", mode: "subagent", hidden: false },
+    { id: "secret", name: "Secret", mode: "all", hidden: true },
+  ])
+  const [current] = createSignal("review")
+  let selected: string | undefined
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
+        <RunAgentSelectBody
+          theme={() => RUN_THEME_FALLBACK.footer}
+          agents={agents}
+          current={current}
+          onClose={() => {}}
+          onSelect={(agent) => (selected = agent)}
+        />
+      </box>
+    ),
+    {
+      width: 100,
+      height: RUN_COMMAND_PANEL_ROWS,
+    },
+  )
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("Select agent")
+    expect(frame).toContain("build")
+    expect(frame).toContain("review")
+    expect(frame).toContain("Review changes")
+    expect(frame).toContain("current")
+    expect(frame).not.toContain("explore")
+    expect(frame).not.toContain("secret")
+
+    app.mockInput.pressEnter()
+    expect(selected).toBe("review")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("direct variant panel renders current variant selector", async () => {
   const [variants] = createSignal(["high", "minimal"])
   const [current] = createSignal<string | undefined>("high")

+ 2 - 0
packages/tui/test/mini/footer.width.test.ts

@@ -10,12 +10,14 @@ describe("run footer width", () => {
     const narrow = footerWidthPolicy(79)
     expect(narrow.dialog.narrow).toBe(true)
     expect(narrow.statusline.showActivityMeta).toBe(false)
+    expect(narrow.statusline.showAgent).toBe(false)
     expect(narrow.statusline.showContextHints).toBe(false)
     expect(narrow.statusline.contextHintLimit).toBe(0)
 
     const compact = footerWidthPolicy(80)
     expect(compact.dialog.narrow).toBe(false)
     expect(compact.statusline.showActivityMeta).toBe(true)
+    expect(compact.statusline.showAgent).toBe(true)
     expect(compact.statusline.showContextHints).toBe(true)
     expect(compact.statusline.contextHintLimit).toBe(1)
 

+ 4 - 0
packages/tui/test/mini/runtime.test.ts

@@ -62,6 +62,7 @@ describe("run interactive runtime", () => {
       variants: ["low", "high"],
     })
     let lifecycle!: LifecycleInput
+    let turnAgent: string | undefined
     let turnModel: { providerID: string; modelID: string } | undefined
     let refreshCatalog: (() => Promise<unknown>) | undefined
     stubCatalogLists(sdk, {
@@ -107,6 +108,7 @@ describe("run interactive runtime", () => {
             catalogLoaded.resolve()
             return {
               runPromptTurn: async (input) => {
+                turnAgent = input.agent
                 turnModel = input.model
                 api.close()
               },
@@ -139,8 +141,10 @@ describe("run interactive runtime", () => {
       selection: { providerID: "test", modelID: "resolved" },
     })
     expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
+    lifecycle.onAgentSelect?.("review")
     ui.submit("hello")
     while (!turnModel) await Bun.sleep(0)
+    expect(turnAgent).toBe("review")
     expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
     await task
   })

+ 10 - 2
packages/tui/test/mini/stream-v2.transport.test.ts

@@ -624,13 +624,17 @@ describe("V2 mini transport", () => {
       ok({ ...promptAdmission(request), admittedSeq: 2 }) as never,
     )
     await transport.queuePromptTurn({
-      agent: undefined,
+      agent: "review",
       model: undefined,
       variant: undefined,
       prompt: { messageID: "msg_next", text: "another", parts: [] },
       files: [],
       includeFiles: false,
     })
+    expect(client.session.switchAgent).toHaveBeenCalledWith(
+      { sessionID: "ses_1", agent: "review" },
+      expect.anything(),
+    )
     expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
     events.push({
       id: "evt_earlier_admission",
@@ -2714,7 +2718,7 @@ describe("V2 mini transport", () => {
     })
 
     await transport.runPromptTurn({
-      agent: undefined,
+      agent: "review",
       model: undefined,
       variant: undefined,
       prompt: {
@@ -2727,6 +2731,10 @@ describe("V2 mini transport", () => {
       includeFiles: true,
     })
 
+    expect(client.session.switchAgent).toHaveBeenCalledWith(
+      { sessionID: "ses_1", agent: "review" },
+      expect.anything(),
+    )
     expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" })
     expect(command).not.toHaveBeenCalled()
     expect(prompt).not.toHaveBeenCalled()