diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 73a7a79963e1..b2f78a99b0e0 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -12,6 +12,7 @@ export namespace Agent { .object({ name: z.string(), description: z.string().optional(), + shortDescription: z.string().optional(), mode: z.enum(["subagent", "primary", "all"]), builtIn: z.boolean(), topP: z.number().optional(), @@ -189,6 +190,7 @@ export namespace Agent { prompt, tools, description, + short_description, temperature, top_p, mode, @@ -213,6 +215,7 @@ export namespace Agent { ...item.tools, } if (description) item.description = description + if (short_description) item.shortDescription = short_description if (temperature != undefined) item.temperature = temperature if (top_p != undefined) item.topP = top_p if (mode) item.mode = mode @@ -262,6 +265,7 @@ export namespace Agent { model: language, schema: z.object({ identifier: z.string(), + shortDescription: z.string(), whenToUse: z.string(), systemPrompt: z.string(), }), diff --git a/packages/opencode/src/agent/generate.txt b/packages/opencode/src/agent/generate.txt index 774277b0fa8f..b4f8ca2a77dd 100644 --- a/packages/opencode/src/agent/generate.txt +++ b/packages/opencode/src/agent/generate.txt @@ -59,6 +59,7 @@ When a user describes what they want an agent to do, you will: Your output must be a valid JSON object with exactly these fields: { "identifier": "A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'code-reviewer', 'api-docs-writer', 'test-generator')", +"shortDescription": "A brief, human-readable summary of the agent's purpose (max 40 characters) for display in agent listings. Should be concise and descriptive, e.g., 'Reviews code for best practices and potential issues'", "whenToUse": "A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases. Ensure you include examples as described above.", "systemPrompt": "The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness" } diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a774c6d026be..1169c7f6bc6c 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -112,6 +112,7 @@ const AgentCreateCommand = cmd({ const frontmatter: any = { description: generated.whenToUse, + short_description: generated.shortDescription, mode: modeResult, } if (Object.keys(tools).length > 0) { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent-details.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent-details.tsx new file mode 100644 index 000000000000..469c09c83f6a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent-details.tsx @@ -0,0 +1,79 @@ +import { createMemo, onMount } from "solid-js" +import { useLocal } from "@tui/context/local" +import { useDialog } from "@tui/ui/dialog" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { DialogAgent } from "./dialog-agent" + +export function DialogAgentDetails(props: { agentName: string }) { + const local = useLocal() + const dialog = useDialog() + const { theme } = useTheme() + const dimensions = useTerminalDimensions() + + const agent = createMemo(() => local.agent.list().find((a) => a.name === props.agentName)) + const maxHeight = Math.floor(dimensions().height / 2) - 4 + + let scroll: ScrollBoxRenderable + + onMount(() => { + dialog.setSize("large") + }) + + useKeyboard((evt) => { + if (evt.name === "escape" || evt.name === "return") { + evt.preventDefault() + dialog.replace(() => ) + return + } + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) { + scroll?.scrollBy(-1) + } + if (evt.name === "down" || (evt.ctrl && evt.name === "n")) { + scroll?.scrollBy(1) + } + if (evt.name === "pageup") { + scroll?.scrollBy(-10) + } + if (evt.name === "pagedown") { + scroll?.scrollBy(10) + } + }) + + return ( + + + + + {props.agentName} + + esc + + + (scroll = r)} + paddingLeft={4} + paddingRight={4} + paddingTop={1} + maxHeight={maxHeight} + > + {agent()?.shortDescription && ( + + {agent()?.shortDescription} + + )} + {agent()?.description && ( + + + {agent()?.description} + + + )} + + + Press esc or enter to go back + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 65aaeb22bf98..d56a22d0eeff 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -1,18 +1,51 @@ -import { createMemo } from "solid-js" +import { createMemo, createSignal } from "solid-js" import { useLocal } from "@tui/context/local" import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" +import { useTerminalDimensions } from "@opentui/solid" +import { Keybind } from "@/util/keybind" +import { Locale } from "@/util/locale" +import { DialogAgentDetails } from "./dialog-agent-details" -export function DialogAgent() { +export function DialogAgent(props: { selectedAgent?: string }) { const local = useLocal() const dialog = useDialog() + const dimensions = useTerminalDimensions() + + const [selectedAgentName, setSelectedAgentName] = createSignal(props.selectedAgent ?? local.agent.current().name) + + const selectedAgentHasDescription = createMemo(() => { + const agent = local.agent.list().find((a) => a.name === selectedAgentName()) + return agent && !agent.builtIn && !!agent.description + }) + + // Dialog width is min(60, terminalWidth - 2), minus ~10 for padding + const availableWidth = createMemo(() => Math.min(60, dimensions().width - 2) - 10) const options = createMemo(() => local.agent.list().map((item) => { + let description: string | undefined + + if (item.builtIn) { + description = "native" + } else if (item.shortDescription) { + description = item.shortDescription + } else if (item.description) { + description = item.description + } + + // Truncate description based on available space after title + const maxDescriptionLength = availableWidth() - item.name.length - 1 + if (description && maxDescriptionLength > 3) { + description = Locale.truncate(description, maxDescriptionLength) + } else if (description && maxDescriptionLength <= 3) { + description = undefined + } + return { value: item.name, title: item.name, - description: item.builtIn ? "native" : item.description, + description, } }), ) @@ -21,11 +54,25 @@ export function DialogAgent() { setSelectedAgentName(option.value)} onSelect={(option) => { local.agent.set(option.value) dialog.clear() }} + keybind={[ + { + keybind: Keybind.parse("ctrl+e")[0], + title: "show details", + get disabled() { + return !selectedAgentHasDescription() + }, + onTrigger: (option) => { + dialog.replace(() => ) + }, + }, + ]} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index b6c5b5f8b959..55418fc57da4 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -26,6 +26,7 @@ export interface DialogSelectProps { onTrigger: (option: DialogSelectOption) => void }[] current?: T + defaultSelected?: T } export interface DialogSelectOption { @@ -53,10 +54,11 @@ export function DialogSelect(props: DialogSelectProps) { }) createEffect(() => { - if (props.current) { - const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, props.current)) - if (currentIndex >= 0) { - setStore("selected", currentIndex) + const initialValue = props.defaultSelected ?? props.current + if (initialValue) { + const initialIndex = flat().findIndex((opt) => isDeepEqual(opt.value, initialValue)) + if (initialIndex >= 0) { + setStore("selected", initialIndex) } } }) @@ -101,10 +103,13 @@ export function DialogSelect(props: DialogSelectProps) { store.filter if (store.filter.length > 0) { setStore("selected", 0) - } else if (props.current) { - const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, props.current)) - if (currentIndex >= 0) { - setStore("selected", currentIndex) + } else { + const initialValue = props.defaultSelected ?? props.current + if (initialValue) { + const initialIndex = flat().findIndex((opt) => isDeepEqual(opt.value, initialValue)) + if (initialIndex >= 0) { + setStore("selected", initialIndex) + } } } scroll.scrollTo(0) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 9b773111c355..843232175d6d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -57,7 +57,7 @@ function init() { }) useKeyboard((evt) => { - if (evt.name === "escape" && store.stack.length > 0) { + if (evt.name === "escape" && store.stack.length > 0 && !evt.defaultPrevented) { const current = store.stack.at(-1)! current.onClose?.() setStore("stack", store.stack.slice(0, -1)) @@ -70,8 +70,14 @@ function init() { let focus: Renderable | null function refocus() { setTimeout(() => { + // A new dialog was opened in the meantime (e.g., navigating between dialogs) + if (store.stack.length > 0) return + if (!focus) return - if (focus.isDestroyed) return + if (focus.isDestroyed) { + focus = null + return + } function find(item: Renderable) { for (const child of item.getChildren()) { if (child === focus) return true @@ -80,8 +86,12 @@ function init() { return false } const found = find(renderer.root) - if (!found) return + if (!found) { + focus = null + return + } focus.focus() + focus = null }, 1) } @@ -97,7 +107,7 @@ function init() { refocus() }, replace(input: any, onClose?: () => void) { - if (store.stack.length === 0) { + if (store.stack.length === 0 && (!focus || focus.isDestroyed)) { focus = renderer.currentFocusedRenderable } for (const item of store.stack) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d38de8a94078..2afdc3db174a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -369,6 +369,7 @@ export namespace Config { tools: z.record(z.string(), z.boolean()).optional(), disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), + short_description: z.string().optional().describe("Short description for display in agent listings"), mode: z.enum(["subagent", "primary", "all"]).optional(), color: z .string() diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index c640f41a7190..536decf6a7b4 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -965,6 +965,10 @@ export type AgentConfig = { * Description of when to use the agent */ description?: string + /** + * Short description for display in agent listings + */ + short_description?: string mode?: "subagent" | "primary" | "all" /** * Hex color code for the agent (e.g., #FF5733) @@ -1542,6 +1546,7 @@ export type File = { export type Agent = { name: string description?: string + shortDescription?: string mode: "subagent" | "primary" | "all" builtIn: boolean topP?: number