Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .agents/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type ToolName =
| 'read_docs'
| 'read_files'
| 'read_subtree'
| 'render_ui'
| 'run_file_change_hooks'
| 'run_terminal_command'
| 'set_messages'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions agents/base2/base2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions agents/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type ToolName =
| 'read_docs'
| 'read_files'
| 'read_subtree'
| 'render_ui'
| 'run_file_change_hooks'
| 'run_terminal_command'
| 'set_messages'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
68 changes: 68 additions & 0 deletions cli/src/components/tools/__tests__/render-ui.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
2 changes: 2 additions & 0 deletions cli/src/components/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -35,6 +36,7 @@ const toolComponentRegistry = new Map<ToolName, ToolComponent>([
[ReadDocsComponent.toolName, ReadDocsComponent],
[ReadFilesComponent.toolName, ReadFilesComponent],
[ReadSubtreeComponent.toolName, ReadSubtreeComponent],
[RenderUIComponent.toolName, RenderUIComponent],
[WriteTodosComponent.toolName, WriteTodosComponent],
[StrReplaceComponent.toolName, StrReplaceComponent],
[SuggestFollowupsComponent.toolName, SuggestFollowupsComponent],
Expand Down
143 changes: 143 additions & 0 deletions cli/src/components/tools/render-ui.tsx
Original file line number Diff line number Diff line change
@@ -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<RenderUIButtonWidget['variant']>

const isRenderUIButtonWidget = (
widget: unknown,
): widget is RenderUIButtonWidget => {
if (widget === null || typeof widget !== 'object') {
return false
}

const candidate = widget as Partial<RenderUIButtonWidget>
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 (
<box
style={{
flexDirection: 'row',
alignItems: 'center',
gap: statusText ? 1 : 0,
}}
>
<Button
onClick={handleClick}
onMouseOver={() => setIsHovered(true)}
onMouseOut={() => setIsHovered(false)}
style={{
backgroundColor,
paddingLeft: 1,
paddingRight: 1,
}}
>
<text>
<span
fg={foregroundColor}
attributes={isHovered ? TextAttributes.BOLD : undefined}
>
{widget.text}
</span>
</text>
</Button>
<text style={{ wrapMode: 'word' }}>
<span fg={status === 'failed' ? theme.error : theme.muted}>
{statusText}
</span>
</text>
</box>
)
}

export const RenderUIComponent = defineToolComponent({
toolName: 'render_ui',

render(toolBlock): ToolRenderConfig {
const widget = toolBlock.input?.widget

if (!isRenderUIButtonWidget(widget)) {
return { content: null }
}

return {
content: <RenderUIButton widget={widget} />,
collapsedPreview: `${widget.text} -> ${widget.link}`,
}
},
})
19 changes: 19 additions & 0 deletions common/src/templates/initial-agents-dir/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type ToolName =
| 'read_docs'
| 'read_files'
| 'read_subtree'
| 'render_ui'
| 'run_file_change_hooks'
| 'run_terminal_command'
| 'set_messages'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
3 changes: 2 additions & 1 deletion common/src/tools/compile-tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
3 changes: 3 additions & 0 deletions common/src/tools/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const TOOLS_WHICH_WONT_FORCE_NEXT_STEP = [
'add_message',
'update_subgoal',
'create_plan',
'render_ui',
'suggest_followups',
'task_completed',
]
Expand All @@ -38,6 +39,7 @@ export const toolNames = [
'read_docs',
'read_files',
'read_subtree',
'render_ui',
'run_file_change_hooks',
'run_terminal_command',
'set_messages',
Expand Down Expand Up @@ -71,6 +73,7 @@ export const publishedTools = [
'read_docs',
'read_files',
'read_subtree',
'render_ui',
'run_file_change_hooks',
'run_terminal_command',
'set_messages',
Expand Down
Loading
Loading