diff --git a/packages/cli/src/session-target.ts b/packages/cli/src/session-target.ts index 40412473ace..975f4829b21 100644 --- a/packages/cli/src/session-target.ts +++ b/packages/cli/src/session-target.ts @@ -74,7 +74,7 @@ export async function resolveSessionTarget(input: { session, location, model: prepared.model, - agent: prepared.agent, + agent: prepared.agent ?? session.agent, resume: selected !== undefined, } } diff --git a/packages/cli/test/session-target.test.ts b/packages/cli/test/session-target.test.ts index fef8faf72a0..ea34aada7f4 100644 --- a/packages/cli/test/session-target.test.ts +++ b/packages/cli/test/session-target.test.ts @@ -78,6 +78,15 @@ describe("session target resolver", () => { expect(order).toEqual(["prepare", "create"]) }) + test("uses the agent resolved by the server for a fresh Session", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(client.location, "get").mockResolvedValue(location("/project")) + spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" }) + + const target = await resolveSessionTarget({ client, prepare }) + expect(target.agent).toBe("review") + }) + test("does not retry an ambiguous Session creation", async () => { const client = OpenCode.make({ baseUrl: "https://opencode.test" }) spyOn(client.location, "get").mockResolvedValue(location("/project")) diff --git a/packages/tui/src/mini/catalog.shared.ts b/packages/tui/src/mini/catalog.shared.ts index 941e44d9c52..2210cb3eca3 100644 --- a/packages/tui/src/mini/catalog.shared.ts +++ b/packages/tui/src/mini/catalog.shared.ts @@ -34,6 +34,7 @@ function runAgent(input: CurrentAgent): RunAgent { return { id: input.id, name: input.name, + description: input.description, mode: input.mode, hidden: input.hidden, } diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 4dff4f8112e..48c11f951c0 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -10,6 +10,7 @@ import type { FooterSubagentTab, MiniSettingChange, MiniSettings, + RunAgent, RunCommand, RunInput, RunProvider, @@ -21,6 +22,7 @@ type PanelEntry = RunFooterMenuItem & { } type CommandEntry = + | (PanelEntry & { action: "agent" }) | (PanelEntry & { action: "model" }) | (PanelEntry & { action: "editor" }) | (PanelEntry & { action: "skill" }) @@ -40,6 +42,11 @@ type ModelEntry = PanelEntry & { current: boolean } +type AgentEntry = PanelEntry & { + id: string + current: boolean +} + type VariantEntry = PanelEntry & { variant: string | undefined current: boolean @@ -341,6 +348,7 @@ export function RunCommandMenuBody(props: { variants: Accessor 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 + agents: Accessor + current: Accessor + onClose: () => void + onSelect: (agent: string) => void + mono?: boolean +}) { + const entries = createMemo(() => + 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 ( + + 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} + /> + + ) +} + export function RunSettingsBody(props: { theme: Accessor settings: Accessor diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index cc5520b4dbe..54b50953bf2 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -74,7 +74,7 @@ type RunFooterOptions = { agents: RunAgent[] references: RunReference[] wrote?: boolean - agentLabel: string + agent: string | undefined modelLabel: string model: RunInput["model"] variant: string | undefined @@ -91,6 +91,7 @@ type RunFooterOptions = { onFormReply: (input: FormReply) => void | Promise onFormCancel: (input: FormCancel) => void | Promise onCycleVariant?: () => CycleResult | void + onAgentSelect?: (agent: string) => void onModelSelect?: (model: NonNullable) => CycleResult | void | Promise onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise onInterrupt?: () => void @@ -169,6 +170,9 @@ export class RunFooter implements FooterApi { private setCommands: Setter private providers: Accessor private setProviders: Setter + private currentAgent: Accessor + private currentAgentID: Accessor + private setCurrentAgentID: Setter private currentModel: Accessor private setCurrentModel: Setter private variants: Accessor @@ -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() 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(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 diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 3a6b622bfaa..956168565cf 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -14,6 +14,7 @@ import { registerOpencodeSpinner } from "../component/register-spinner" import { createColors, createFrames } from "../ui/spinner" import { RUN_SUBAGENT_PANEL_ROWS, + RunAgentSelectBody, RunCommandMenuBody, RunModelSelectBody, RunQueuedPromptSelectBody, @@ -76,6 +77,8 @@ type RunFooterViewProps = { references: () => RunReference[] commands: () => RunCommand[] | undefined providers: () => RunProvider[] | undefined + currentAgent: () => string + currentAgentID: () => string | undefined currentModel: () => RunInput["model"] variants: () => string[] currentVariant: () => string | undefined @@ -99,6 +102,7 @@ type RunFooterViewProps = { onExitRequest?: () => boolean onRequestExit?: (fn: (() => boolean) | undefined) => void onExit: () => void + onAgentSelect: (agent: string) => void onModelSelect: (model: NonNullable) => 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} /> + + { + props.onAgentSelect(agent) + closePanel() + }} + mono={props.mono} + /> + + + {info().agent} + {props.mono ? " - " : " · "} + {info().model} {(variant) => {variant()}} diff --git a/packages/tui/src/mini/footer.width.ts b/packages/tui/src/mini/footer.width.ts index 35670d0ef70..e7fd07b5bf9 100644 --- a/packages/tui/src/mini/footer.width.ts +++ b/packages/tui/src/mini/footer.width.ts @@ -20,6 +20,7 @@ export function footerWidthPolicy(width: number) { }, statusline: { showActivityMeta: compact, + showAgent: compact, showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint, showModel: width >= FOOTER_WIDTH_BREAKPOINTS.model, showModelVariant: width >= FOOTER_WIDTH_BREAKPOINTS.modelVariant, diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 9c43d7fd6f3..8bc68066899 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -11,7 +11,6 @@ import path from "path" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { isDefaultTitle } from "../util/session" -import { Locale } from "../util/locale" import { entrySplash, exitSplash, splashMeta } from "./splash" import { resolveRunTheme } from "./theme" import type { @@ -45,11 +44,6 @@ type CycleResult = { variants?: string[] } -type FooterLabels = { - agentLabel: string - modelLabel: string -} - export type LifecycleInput = { host: MiniHost getDirectory: () => string @@ -70,6 +64,7 @@ export type LifecycleInput = { onFormReply: (input: FormReply) => void | Promise onFormCancel: (input: FormCancel) => void | Promise onCycleVariant?: () => CycleResult | void + onAgentSelect?: (agent: string) => void onModelSelect?: (model: NonNullable) => CycleResult | void | Promise onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise onInterrupt?: () => void @@ -123,14 +118,6 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) { } } -function footerLabels(input: Pick): 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 { + state.agent = agent + }, onModelSelect: async (model) => { if (state.model?.providerID === model.providerID && state.model.modelID === model.modelID) { return diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 7fcd3054f96..964d78e1efb 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -1553,6 +1553,8 @@ export async function createSessionTransport(input: StreamInput): Promise { 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={() => {}} diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 418689f14c6..d200fe80ad5 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -8,6 +8,7 @@ import { Keymap } from "../../src/context/keymap" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS, + RunAgentSelectBody, RunCommandMenuBody, RunModelSelectBody, RunQueuedPromptSelectBody, @@ -26,6 +27,7 @@ import type { FooterView, MiniSettingChange, MiniSettings, + RunAgent, RunCommand, RunInput, RunPrompt, @@ -110,6 +112,7 @@ async function renderFooter( commands?: RunCommand[] theme?: () => RunTheme providers?: RunProvider[] + currentAgent?: string currentModel?: RunInput["model"] currentVariant?: string subagents?: FooterSubagentState @@ -122,6 +125,7 @@ async function renderFooter( onFormReply?: (input: unknown) => void miniSettings?: MiniSettings mono?: boolean + onStatus?: (status: string) => void onMiniSettingChange?: (change: MiniSettingChange) => void } = {}, ) { @@ -144,6 +148,8 @@ async function renderFooter( references={() => []} commands={() => input.commands ?? []} providers={() => input.providers} + currentAgent={() => input.currentAgent ?? "Build"} + currentAgentID={() => input.currentAgent?.toLowerCase() ?? "build"} currentModel={() => input.currentModel} variants={() => []} currentVariant={() => input.currentVariant} @@ -162,11 +168,12 @@ async function renderFooter( onEditorOpen={async () => undefined} onInputClear={() => {}} onExit={() => {}} + onAgentSelect={() => {}} onModelSelect={() => {}} onVariantSelect={() => {}} onRows={() => {}} onLayout={() => {}} - onStatus={() => {}} + onStatus={(status) => input.onStatus?.(status)} onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)} /> @@ -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([ + { 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( + () => ( + + RUN_THEME_FALLBACK.footer} + agents={agents} + current={current} + onClose={() => {}} + onSelect={(agent) => (selected = agent)} + /> + + ), + { + 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("high") diff --git a/packages/tui/test/mini/footer.width.test.ts b/packages/tui/test/mini/footer.width.test.ts index fa8ec5d31a7..244ec83cb32 100644 --- a/packages/tui/test/mini/footer.width.test.ts +++ b/packages/tui/test/mini/footer.width.test.ts @@ -10,12 +10,14 @@ describe("run footer width", () => { const narrow = footerWidthPolicy(79) expect(narrow.dialog.narrow).toBe(true) expect(narrow.statusline.showActivityMeta).toBe(false) + expect(narrow.statusline.showAgent).toBe(false) expect(narrow.statusline.showContextHints).toBe(false) expect(narrow.statusline.contextHintLimit).toBe(0) const compact = footerWidthPolicy(80) expect(compact.dialog.narrow).toBe(false) expect(compact.statusline.showActivityMeta).toBe(true) + expect(compact.statusline.showAgent).toBe(true) expect(compact.statusline.showContextHints).toBe(true) expect(compact.statusline.contextHintLimit).toBe(1) diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index 1955b4a4700..e55e150bc00 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -62,6 +62,7 @@ describe("run interactive runtime", () => { variants: ["low", "high"], }) let lifecycle!: LifecycleInput + let turnAgent: string | undefined let turnModel: { providerID: string; modelID: string } | undefined let refreshCatalog: (() => Promise) | 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 }) diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 98ff08aac76..efe18ac64ae 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -624,13 +624,17 @@ describe("V2 mini transport", () => { ok({ ...promptAdmission(request), admittedSeq: 2 }) as never, ) await transport.queuePromptTurn({ - agent: undefined, + agent: "review", model: undefined, variant: undefined, prompt: { messageID: "msg_next", text: "another", parts: [] }, files: [], includeFiles: false, }) + expect(client.session.switchAgent).toHaveBeenCalledWith( + { sessionID: "ses_1", agent: "review" }, + expect.anything(), + ) expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything()) events.push({ id: "evt_earlier_admission", @@ -2714,7 +2718,7 @@ describe("V2 mini transport", () => { }) await transport.runPromptTurn({ - agent: undefined, + agent: "review", model: undefined, variant: undefined, prompt: { @@ -2727,6 +2731,10 @@ describe("V2 mini transport", () => { includeFiles: true, }) + expect(client.session.switchAgent).toHaveBeenCalledWith( + { sessionID: "ses_1", agent: "review" }, + expect.anything(), + ) expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" }) expect(command).not.toHaveBeenCalled() expect(prompt).not.toHaveBeenCalled()