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

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