mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-25 00:33:59 +00:00
parent
648183cecb
commit
a3e2cc0dcd
16 changed files with 268 additions and 33 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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={() => {}}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue