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

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

View file

@ -78,6 +78,15 @@ describe("session target resolver", () => {
expect(order).toEqual(["prepare", "create"]) 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 () => { test("does not retry an ambiguous Session creation", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project")) spyOn(client.location, "get").mockResolvedValue(location("/project"))

View file

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

View file

@ -10,6 +10,7 @@ import type {
FooterSubagentTab, FooterSubagentTab,
MiniSettingChange, MiniSettingChange,
MiniSettings, MiniSettings,
RunAgent,
RunCommand, RunCommand,
RunInput, RunInput,
RunProvider, RunProvider,
@ -21,6 +22,7 @@ type PanelEntry = RunFooterMenuItem & {
} }
type CommandEntry = type CommandEntry =
| (PanelEntry & { action: "agent" })
| (PanelEntry & { action: "model" }) | (PanelEntry & { action: "model" })
| (PanelEntry & { action: "editor" }) | (PanelEntry & { action: "editor" })
| (PanelEntry & { action: "skill" }) | (PanelEntry & { action: "skill" })
@ -40,6 +42,11 @@ type ModelEntry = PanelEntry & {
current: boolean current: boolean
} }
type AgentEntry = PanelEntry & {
id: string
current: boolean
}
type VariantEntry = PanelEntry & { type VariantEntry = PanelEntry & {
variant: string | undefined variant: string | undefined
current: boolean current: boolean
@ -341,6 +348,7 @@ export function RunCommandMenuBody(props: {
variants: Accessor<string[]> variants: Accessor<string[]>
variantCycle: string variantCycle: string
onClose: () => void onClose: () => void
onAgent: () => void
onModel: () => void onModel: () => void
onEditor: () => void onEditor: () => void
onSkill: () => void onSkill: () => void
@ -419,6 +427,11 @@ export function RunCommandMenuBody(props: {
] ]
: [] : []
const agent: CommandEntry[] = [ const agent: CommandEntry[] = [
{
action: "agent",
category: "Agent",
display: "Switch agent",
},
{ {
action: "model", action: "model",
category: "Agent", category: "Agent",
@ -471,6 +484,11 @@ export function RunCommandMenuBody(props: {
] ]
}) })
const pick = (item: CommandEntry) => { const pick = (item: CommandEntry) => {
if (item.action === "agent") {
props.onAgent()
return
}
if (item.action === "model") { if (item.action === "model") {
props.onModel() props.onModel()
return 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: { export function RunSettingsBody(props: {
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
settings: Accessor<MiniSettings> settings: Accessor<MiniSettings>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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