Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -189,6 +190,7 @@ export namespace Agent {
prompt,
tools,
description,
short_description,
temperature,
top_p,
mode,
Expand All @@ -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
Expand Down Expand Up @@ -262,6 +265,7 @@ export namespace Agent {
model: language,
schema: z.object({
identifier: z.string(),
shortDescription: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/agent/generate.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(() => <DialogAgent selectedAgent={props.agentName} />)
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 (
<box gap={1} paddingBottom={1}>
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.agentName}
</text>
<text fg={theme.textMuted}>esc</text>
</box>
</box>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
paddingLeft={4}
paddingRight={4}
paddingTop={1}
maxHeight={maxHeight}
>
{agent()?.shortDescription && (
<text fg={theme.textMuted} wrapMode="word" marginBottom={1}>
{agent()?.shortDescription}
</text>
)}
{agent()?.description && (
<box>
<text fg={theme.text} wrapMode="word">
{agent()?.description}
</text>
</box>
)}
</scrollbox>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Press esc or enter to go back</text>
</box>
</box>
)
}
53 changes: 50 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
Original file line number Diff line number Diff line change
@@ -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,
}
}),
)
Expand All @@ -21,11 +54,25 @@ export function DialogAgent() {
<DialogSelect
title="Select agent"
current={local.agent.current().name}
defaultSelected={props.selectedAgent}
options={options()}
onMove={(option) => 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(() => <DialogAgentDetails agentName={option.value} />)
},
},
]}
/>
)
}
21 changes: 13 additions & 8 deletions packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
onTrigger: (option: DialogSelectOption<T>) => void
}[]
current?: T
defaultSelected?: T
}

export interface DialogSelectOption<T = any> {
Expand Down Expand Up @@ -53,10 +54,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})

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)
}
}
})
Expand Down Expand Up @@ -101,10 +103,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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)
Expand Down
18 changes: 14 additions & 4 deletions packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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)
}

Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1542,6 +1546,7 @@ export type File = {
export type Agent = {
name: string
description?: string
shortDescription?: string
mode: "subagent" | "primary" | "all"
builtIn: boolean
topP?: number
Expand Down
Loading