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

Fixes #36121
This commit is contained in:
Simon Klee 2026-07-22 13:14:10 +02:00 committed by GitHub
commit a3e2cc0dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 268 additions and 33 deletions

View file

@ -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,
}

View file

@ -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>

View file

@ -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

View file

@ -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>}

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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" }