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
parent 648183cecb
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,
location,
model: prepared.model,
agent: prepared.agent,
agent: prepared.agent ?? session.agent,
resume: selected !== undefined,
}
}

View file

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

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

View file

@ -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={() => {}}

View file

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

View file

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

View file

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

View file

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