From e1f128e175e1b0243ae3c739db6f6dd45d90c450 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 30 Apr 2026 13:25:18 -0700 Subject: [PATCH] Add render UI button tool --- .agents/types/tools.ts | 19 +++ agents/base2/base2.ts | 1 + agents/types/tools.ts | 19 +++ .../tools/__tests__/render-ui.test.tsx | 68 +++++++++ cli/src/components/tools/registry.ts | 2 + cli/src/components/tools/render-ui.tsx | 143 ++++++++++++++++++ .../initial-agents-dir/types/tools.ts | 19 +++ common/src/tools/compile-tool-definitions.ts | 3 +- common/src/tools/constants.ts | 3 + common/src/tools/list.ts | 2 + common/src/tools/params/tool/render-ui.ts | 97 ++++++++++++ .../agent-runtime/src/tools/handlers/list.ts | 2 + .../src/tools/handlers/tool/render-ui.ts | 15 ++ 13 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 cli/src/components/tools/__tests__/render-ui.test.tsx create mode 100644 cli/src/components/tools/render-ui.tsx create mode 100644 common/src/tools/params/tool/render-ui.ts create mode 100644 packages/agent-runtime/src/tools/handlers/tool/render-ui.ts diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts index 649d9af331..754e54d78a 100644 --- a/.agents/types/tools.ts +++ b/.agents/types/tools.ts @@ -16,6 +16,7 @@ export type ToolName = | 'read_docs' | 'read_files' | 'read_subtree' + | 'render_ui' | 'run_file_change_hooks' | 'run_terminal_command' | 'set_messages' @@ -47,6 +48,7 @@ export interface ToolParamsMap { read_docs: ReadDocsParams read_files: ReadFilesParams read_subtree: ReadSubtreeParams + render_ui: RenderUiParams run_file_change_hooks: RunFileChangeHooksParams run_terminal_command: RunTerminalCommandParams set_messages: SetMessagesParams @@ -229,6 +231,23 @@ export interface ReadSubtreeParams { maxTokens?: number } +/** + * Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link. + */ +export interface RenderUiParams { + /** The UI widget to render. */ + widget: { + /** Widget type. Currently, the only supported widget is button. */ + type: 'button' + /** Short button label shown to the user. */ + text: string + /** The http:// or https:// URL to open when the user clicks the button. */ + link: string + /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */ + variant?: 'primary' | 'secondary' + } +} + /** * Parameters for run_file_change_hooks tool */ diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index d398b2a920..32843f5076 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -70,6 +70,7 @@ export function createBase2( 'read_subtree', !isFast && 'write_todos', !isFast && !noAskUser && 'suggest_followups', + !isFast && 'render_ui', 'str_replace', 'write_file', !isFree && 'propose_str_replace', diff --git a/agents/types/tools.ts b/agents/types/tools.ts index d5ad314150..9cfe1cdf2e 100644 --- a/agents/types/tools.ts +++ b/agents/types/tools.ts @@ -17,6 +17,7 @@ export type ToolName = | 'read_docs' | 'read_files' | 'read_subtree' + | 'render_ui' | 'run_file_change_hooks' | 'run_terminal_command' | 'set_messages' @@ -50,6 +51,7 @@ export interface ToolParamsMap { read_docs: ReadDocsParams read_files: ReadFilesParams read_subtree: ReadSubtreeParams + render_ui: RenderUiParams run_file_change_hooks: RunFileChangeHooksParams run_terminal_command: RunTerminalCommandParams set_messages: SetMessagesParams @@ -274,6 +276,23 @@ export interface ReadSubtreeParams { maxTokens?: number } +/** + * Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link. + */ +export interface RenderUiParams { + /** The UI widget to render. */ + widget: { + /** Widget type. Currently, the only supported widget is button. */ + type: 'button' + /** Short button label shown to the user. */ + text: string + /** The http:// or https:// URL to open when the user clicks the button. */ + link: string + /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */ + variant?: 'primary' | 'secondary' + } +} + /** * Parameters for run_file_change_hooks tool */ diff --git a/cli/src/components/tools/__tests__/render-ui.test.tsx b/cli/src/components/tools/__tests__/render-ui.test.tsx new file mode 100644 index 0000000000..24938c7cb2 --- /dev/null +++ b/cli/src/components/tools/__tests__/render-ui.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../../hooks/use-theme' +import { chatThemes } from '../../../utils/theme-system' +import { RenderUIComponent } from '../render-ui' + +import type { ToolBlock } from '../types' + +initializeThemeStore() + +const createToolBlock = ( + input: unknown, +): ToolBlock & { toolName: 'render_ui' } => ({ + type: 'tool', + toolName: 'render_ui', + toolCallId: 'test-render-ui-call-id', + input, +}) + +describe('RenderUIComponent', () => { + test('renders a button widget', () => { + const result = RenderUIComponent.render( + createToolBlock({ + widget: { + type: 'button', + text: 'Open preview', + link: 'https://example.com/preview', + variant: 'primary', + }, + }), + chatThemes.light, + { + availableWidth: 80, + indentationOffset: 0, + labelWidth: 10, + }, + ) + + expect(result.collapsedPreview).toBe( + 'Open preview -> https://example.com/preview', + ) + expect(result.content).toBeDefined() + expect(renderToStaticMarkup(<>{result.content})).toContain( + 'Open preview', + ) + }) + + test('returns no content for unsupported widgets', () => { + const result = RenderUIComponent.render( + createToolBlock({ + widget: { + type: 'slider', + text: 'Volume', + }, + }), + chatThemes.light, + { + availableWidth: 80, + indentationOffset: 0, + labelWidth: 10, + }, + ) + + expect(result.content).toBeNull() + }) +}) diff --git a/cli/src/components/tools/registry.ts b/cli/src/components/tools/registry.ts index 11bbafe802..0ec72715cd 100644 --- a/cli/src/components/tools/registry.ts +++ b/cli/src/components/tools/registry.ts @@ -5,6 +5,7 @@ import { ListDirectoryComponent } from './list-directory' import { ReadDocsComponent } from './read-docs' import { ReadFilesComponent } from './read-files' import { ReadSubtreeComponent } from './read-subtree' +import { RenderUIComponent } from './render-ui' import { RunTerminalCommandComponent } from './run-terminal-command' import { SkillComponent } from './skill' import { StrReplaceComponent } from './str-replace' @@ -35,6 +36,7 @@ const toolComponentRegistry = new Map([ [ReadDocsComponent.toolName, ReadDocsComponent], [ReadFilesComponent.toolName, ReadFilesComponent], [ReadSubtreeComponent.toolName, ReadSubtreeComponent], + [RenderUIComponent.toolName, RenderUIComponent], [WriteTodosComponent.toolName, WriteTodosComponent], [StrReplaceComponent.toolName, StrReplaceComponent], [SuggestFollowupsComponent.toolName, SuggestFollowupsComponent], diff --git a/cli/src/components/tools/render-ui.tsx b/cli/src/components/tools/render-ui.tsx new file mode 100644 index 0000000000..3398b2a4c6 --- /dev/null +++ b/cli/src/components/tools/render-ui.tsx @@ -0,0 +1,143 @@ +import { TextAttributes } from '@opentui/core' +import { useCallback, useState } from 'react' + +import { defineToolComponent } from './types' +import { useTheme } from '../../hooks/use-theme' +import { safeOpen } from '../../utils/open-url' +import { Button } from '../button' + +import type { ChatTheme } from '../../types/theme-system' +import type { ToolRenderConfig } from './types' +import type { RenderUIButtonWidget } from '@codebuff/common/tools/params/tool/render-ui' + +type RenderUIButtonVariant = NonNullable + +const isRenderUIButtonWidget = ( + widget: unknown, +): widget is RenderUIButtonWidget => { + if (widget === null || typeof widget !== 'object') { + return false + } + + const candidate = widget as Partial + return ( + candidate.type === 'button' && + typeof candidate.text === 'string' && + candidate.text.trim().length > 0 && + typeof candidate.link === 'string' && + candidate.link.trim().length > 0 && + (candidate.variant === undefined || + candidate.variant === 'primary' || + candidate.variant === 'secondary') + ) +} + +const getButtonColors = ( + theme: ChatTheme, + variant: RenderUIButtonVariant, + isHovered: boolean, + status: 'idle' | 'opened' | 'failed', +) => { + if (status === 'failed') { + return { + backgroundColor: theme.surface, + foregroundColor: theme.error, + } + } + + if (status === 'opened') { + return { + backgroundColor: theme.surface, + foregroundColor: theme.success, + } + } + + if (variant === 'secondary') { + return { + backgroundColor: isHovered ? theme.surfaceHover : theme.surface, + foregroundColor: theme.foreground, + } + } + + return { + backgroundColor: theme.primary, + foregroundColor: theme.name === 'dark' ? '#111827' : '#ffffff', + } +} + +const RenderUIButton = ({ widget }: { widget: RenderUIButtonWidget }) => { + const theme = useTheme() + const [isHovered, setIsHovered] = useState(false) + const [status, setStatus] = useState<'idle' | 'opened' | 'failed'>('idle') + const variant = widget.variant ?? 'primary' + const { backgroundColor, foregroundColor } = getButtonColors( + theme, + variant, + isHovered, + status, + ) + + const handleClick = useCallback(async () => { + const opened = await safeOpen(widget.link) + setStatus(opened ? 'opened' : 'failed') + }, [widget.link]) + + const statusText = + status === 'opened' + ? 'Opened' + : status === 'failed' + ? `Could not open: ${widget.link}` + : '' + + return ( + + + + + {statusText} + + + + ) +} + +export const RenderUIComponent = defineToolComponent({ + toolName: 'render_ui', + + render(toolBlock): ToolRenderConfig { + const widget = toolBlock.input?.widget + + if (!isRenderUIButtonWidget(widget)) { + return { content: null } + } + + return { + content: , + collapsedPreview: `${widget.text} -> ${widget.link}`, + } + }, +}) diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index d5ad314150..9cfe1cdf2e 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -17,6 +17,7 @@ export type ToolName = | 'read_docs' | 'read_files' | 'read_subtree' + | 'render_ui' | 'run_file_change_hooks' | 'run_terminal_command' | 'set_messages' @@ -50,6 +51,7 @@ export interface ToolParamsMap { read_docs: ReadDocsParams read_files: ReadFilesParams read_subtree: ReadSubtreeParams + render_ui: RenderUiParams run_file_change_hooks: RunFileChangeHooksParams run_terminal_command: RunTerminalCommandParams set_messages: SetMessagesParams @@ -274,6 +276,23 @@ export interface ReadSubtreeParams { maxTokens?: number } +/** + * Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link. + */ +export interface RenderUiParams { + /** The UI widget to render. */ + widget: { + /** Widget type. Currently, the only supported widget is button. */ + type: 'button' + /** Short button label shown to the user. */ + text: string + /** The http:// or https:// URL to open when the user clicks the button. */ + link: string + /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */ + variant?: 'primary' | 'secondary' + } +} + /** * Parameters for run_file_change_hooks tool */ diff --git a/common/src/tools/compile-tool-definitions.ts b/common/src/tools/compile-tool-definitions.ts index b84a49f955..fb478324d5 100644 --- a/common/src/tools/compile-tool-definitions.ts +++ b/common/src/tools/compile-tool-definitions.ts @@ -111,9 +111,10 @@ function getTypeFromJsonSchema(prop: any): string { if (prop.const !== undefined) { return JSON.stringify(prop.const) } + if (prop.type === 'string') { if (prop.enum) { - return prop.enum.map((v: string) => `"${v}"`).join(' | ') + return prop.enum.map((v: string) => JSON.stringify(v)).join(' | ') } return 'string' } diff --git a/common/src/tools/constants.ts b/common/src/tools/constants.ts index 452ba09b88..b34f890bcd 100644 --- a/common/src/tools/constants.ts +++ b/common/src/tools/constants.ts @@ -14,6 +14,7 @@ export const TOOLS_WHICH_WONT_FORCE_NEXT_STEP = [ 'add_message', 'update_subgoal', 'create_plan', + 'render_ui', 'suggest_followups', 'task_completed', ] @@ -38,6 +39,7 @@ export const toolNames = [ 'read_docs', 'read_files', 'read_subtree', + 'render_ui', 'run_file_change_hooks', 'run_terminal_command', 'set_messages', @@ -71,6 +73,7 @@ export const publishedTools = [ 'read_docs', 'read_files', 'read_subtree', + 'render_ui', 'run_file_change_hooks', 'run_terminal_command', 'set_messages', diff --git a/common/src/tools/list.ts b/common/src/tools/list.ts index 7834ebd514..9b3d3ba687 100644 --- a/common/src/tools/list.ts +++ b/common/src/tools/list.ts @@ -19,6 +19,7 @@ import { proposeWriteFileParams } from './params/tool/propose-write-file' import { readDocsParams } from './params/tool/read-docs' import { readFilesParams } from './params/tool/read-files' import { readSubtreeParams } from './params/tool/read-subtree' +import { renderUIParams } from './params/tool/render-ui' import { runFileChangeHooksParams } from './params/tool/run-file-change-hooks' import { runTerminalCommandParams } from './params/tool/run-terminal-command' import { setMessagesParams } from './params/tool/set-messages' @@ -58,6 +59,7 @@ export const toolParams = { read_docs: readDocsParams, read_files: readFilesParams, read_subtree: readSubtreeParams, + render_ui: renderUIParams, run_file_change_hooks: runFileChangeHooksParams, run_terminal_command: runTerminalCommandParams, set_messages: setMessagesParams, diff --git a/common/src/tools/params/tool/render-ui.ts b/common/src/tools/params/tool/render-ui.ts new file mode 100644 index 0000000000..183d3ab090 --- /dev/null +++ b/common/src/tools/params/tool/render-ui.ts @@ -0,0 +1,97 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const toolName = 'render_ui' +const endsAgentStep = false + +const buttonLinkSchema = z + .string() + .url() + .refine( + (value) => { + try { + const url = new URL(value) + return url.protocol === 'https:' || url.protocol === 'http:' + } catch { + return false + } + }, + { message: 'Button links must use http:// or https://' }, + ) + +const buttonWidgetSchema = z.object({ + type: z + .literal('button') + .describe('Widget type. Currently, the only supported widget is button.'), + text: z + .string() + .min(1) + .max(80) + .describe('Short button label shown to the user.'), + link: buttonLinkSchema.describe( + 'The http:// or https:// URL to open when the user clicks the button.', + ), + variant: z + .enum(['primary', 'secondary']) + .optional() + .default('primary') + .describe( + 'Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions.', + ), +}) + +export type RenderUIButtonWidget = z.infer + +const widgetSchema = z.discriminatedUnion('type', [buttonWidgetSchema]) + +const inputSchema = z + .object({ + widget: widgetSchema.describe('The UI widget to render.'), + }) + .describe( + 'Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link.', + ) + +const outputSchema = z.object({ + message: z.string(), +}) + +const description = ` +Render a small interactive UI widget in the Codebuff CLI. + +Currently supported widgets: +- button: renders a clickable button with text and an http(s) link. + +Use this when the user should click a clear action, such as opening a generated report, documentation page, checkout page, deployment URL, preview, or dashboard. + +Color variants: +- primary: the main action +- secondary: a lower-emphasis action + +Keep button text short and action-oriented. + +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + widget: { + type: 'button', + text: 'Open preview', + link: 'https://example.com/preview', + variant: 'primary', + }, + }, + endsAgentStep, +})} +`.trim() + +export const renderUIParams = { + toolName, + endsAgentStep, + description, + inputSchema, + outputSchema: jsonToolResultSchema(outputSchema), +} satisfies $ToolParams diff --git a/packages/agent-runtime/src/tools/handlers/list.ts b/packages/agent-runtime/src/tools/handlers/list.ts index 6543669963..32df1f6784 100644 --- a/packages/agent-runtime/src/tools/handlers/list.ts +++ b/packages/agent-runtime/src/tools/handlers/list.ts @@ -16,6 +16,7 @@ import { handleProposeWriteFile } from './tool/propose-write-file' import { handleReadDocs } from './tool/read-docs' import { handleReadFiles } from './tool/read-files' import { handleReadSubtree } from './tool/read-subtree' +import { handleRenderUI } from './tool/render-ui' import { handleRunFileChangeHooks } from './tool/run-file-change-hooks' import { handleRunTerminalCommand } from './tool/run-terminal-command' import { handleSetMessages } from './tool/set-messages' @@ -63,6 +64,7 @@ export const codebuffToolHandlers = { read_docs: handleReadDocs, read_files: handleReadFiles, read_subtree: handleReadSubtree, + render_ui: handleRenderUI, run_file_change_hooks: handleRunFileChangeHooks, run_terminal_command: handleRunTerminalCommand, set_messages: handleSetMessages, diff --git a/packages/agent-runtime/src/tools/handlers/tool/render-ui.ts b/packages/agent-runtime/src/tools/handlers/tool/render-ui.ts new file mode 100644 index 0000000000..7f94c0615e --- /dev/null +++ b/packages/agent-runtime/src/tools/handlers/tool/render-ui.ts @@ -0,0 +1,15 @@ +import type { CodebuffToolHandlerFunction } from '../handler-function-type' +import type { + CodebuffToolCall, + CodebuffToolOutput, +} from '@codebuff/common/tools/list' + +export const handleRenderUI = (async ({ + previousToolCallFinished, +}: { + previousToolCallFinished: Promise + toolCall: CodebuffToolCall<'render_ui'> +}): Promise<{ output: CodebuffToolOutput<'render_ui'> }> => { + await previousToolCallFinished + return { output: [{ type: 'json', value: { message: 'UI rendered.' } }] } +}) satisfies CodebuffToolHandlerFunction<'render_ui'>