Skip to content

Commit 29ceaca

Browse files
committed
Add render UI button tool
1 parent e5a93b2 commit 29ceaca

13 files changed

Lines changed: 395 additions & 1 deletion

File tree

.agents/types/tools.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type ToolName =
1616
| 'read_docs'
1717
| 'read_files'
1818
| 'read_subtree'
19+
| 'render_ui'
1920
| 'run_file_change_hooks'
2021
| 'run_terminal_command'
2122
| 'set_messages'
@@ -47,6 +48,7 @@ export interface ToolParamsMap {
4748
read_docs: ReadDocsParams
4849
read_files: ReadFilesParams
4950
read_subtree: ReadSubtreeParams
51+
render_ui: RenderUiParams
5052
run_file_change_hooks: RunFileChangeHooksParams
5153
run_terminal_command: RunTerminalCommandParams
5254
set_messages: SetMessagesParams
@@ -229,6 +231,23 @@ export interface ReadSubtreeParams {
229231
maxTokens?: number
230232
}
231233

234+
/**
235+
* Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link.
236+
*/
237+
export interface RenderUiParams {
238+
/** The UI widget to render. */
239+
widget: {
240+
/** Widget type. Currently, the only supported widget is button. */
241+
type: 'button'
242+
/** Short button label shown to the user. */
243+
text: string
244+
/** The http:// or https:// URL to open when the user clicks the button. */
245+
link: string
246+
/** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */
247+
variant?: 'primary' | 'secondary'
248+
}
249+
}
250+
232251
/**
233252
* Parameters for run_file_change_hooks tool
234253
*/

agents/base2/base2.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function createBase2(
6969
'read_subtree',
7070
!isFast && 'write_todos',
7171
!isFast && !noAskUser && 'suggest_followups',
72+
!isFast && 'render_ui',
7273
'str_replace',
7374
'write_file',
7475
!isFree && 'propose_str_replace',

agents/types/tools.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type ToolName =
1616
| 'read_docs'
1717
| 'read_files'
1818
| 'read_subtree'
19+
| 'render_ui'
1920
| 'run_file_change_hooks'
2021
| 'run_terminal_command'
2122
| 'set_messages'
@@ -48,6 +49,7 @@ export interface ToolParamsMap {
4849
read_docs: ReadDocsParams
4950
read_files: ReadFilesParams
5051
read_subtree: ReadSubtreeParams
52+
render_ui: RenderUiParams
5153
run_file_change_hooks: RunFileChangeHooksParams
5254
run_terminal_command: RunTerminalCommandParams
5355
set_messages: SetMessagesParams
@@ -231,6 +233,23 @@ export interface ReadSubtreeParams {
231233
maxTokens?: number
232234
}
233235

236+
/**
237+
* Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link.
238+
*/
239+
export interface RenderUiParams {
240+
/** The UI widget to render. */
241+
widget: {
242+
/** Widget type. Currently, the only supported widget is button. */
243+
type: 'button'
244+
/** Short button label shown to the user. */
245+
text: string
246+
/** The http:// or https:// URL to open when the user clicks the button. */
247+
link: string
248+
/** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */
249+
variant?: 'primary' | 'secondary'
250+
}
251+
}
252+
234253
/**
235254
* Parameters for run_file_change_hooks tool
236255
*/
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import React from 'react'
3+
import { renderToStaticMarkup } from 'react-dom/server'
4+
5+
import { initializeThemeStore } from '../../../hooks/use-theme'
6+
import { chatThemes } from '../../../utils/theme-system'
7+
import { RenderUIComponent } from '../render-ui'
8+
9+
import type { ToolBlock } from '../types'
10+
11+
initializeThemeStore()
12+
13+
const createToolBlock = (
14+
input: unknown,
15+
): ToolBlock & { toolName: 'render_ui' } => ({
16+
type: 'tool',
17+
toolName: 'render_ui',
18+
toolCallId: 'test-render-ui-call-id',
19+
input,
20+
})
21+
22+
describe('RenderUIComponent', () => {
23+
test('renders a button widget', () => {
24+
const result = RenderUIComponent.render(
25+
createToolBlock({
26+
widget: {
27+
type: 'button',
28+
text: 'Open preview',
29+
link: 'https://example.com/preview',
30+
variant: 'primary',
31+
},
32+
}),
33+
chatThemes.light,
34+
{
35+
availableWidth: 80,
36+
indentationOffset: 0,
37+
labelWidth: 10,
38+
},
39+
)
40+
41+
expect(result.collapsedPreview).toBe(
42+
'Open preview -> https://example.com/preview',
43+
)
44+
expect(result.content).toBeDefined()
45+
expect(renderToStaticMarkup(<>{result.content}</>)).toContain(
46+
'Open preview',
47+
)
48+
})
49+
50+
test('returns no content for unsupported widgets', () => {
51+
const result = RenderUIComponent.render(
52+
createToolBlock({
53+
widget: {
54+
type: 'slider',
55+
text: 'Volume',
56+
},
57+
}),
58+
chatThemes.light,
59+
{
60+
availableWidth: 80,
61+
indentationOffset: 0,
62+
labelWidth: 10,
63+
},
64+
)
65+
66+
expect(result.content).toBeNull()
67+
})
68+
})

cli/src/components/tools/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ListDirectoryComponent } from './list-directory'
55
import { ReadDocsComponent } from './read-docs'
66
import { ReadFilesComponent } from './read-files'
77
import { ReadSubtreeComponent } from './read-subtree'
8+
import { RenderUIComponent } from './render-ui'
89
import { RunTerminalCommandComponent } from './run-terminal-command'
910
import { SkillComponent } from './skill'
1011
import { StrReplaceComponent } from './str-replace'
@@ -35,6 +36,7 @@ const toolComponentRegistry = new Map<ToolName, ToolComponent>([
3536
[ReadDocsComponent.toolName, ReadDocsComponent],
3637
[ReadFilesComponent.toolName, ReadFilesComponent],
3738
[ReadSubtreeComponent.toolName, ReadSubtreeComponent],
39+
[RenderUIComponent.toolName, RenderUIComponent],
3840
[WriteTodosComponent.toolName, WriteTodosComponent],
3941
[StrReplaceComponent.toolName, StrReplaceComponent],
4042
[SuggestFollowupsComponent.toolName, SuggestFollowupsComponent],
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { useCallback, useState } from 'react'
3+
4+
import { defineToolComponent } from './types'
5+
import { useTheme } from '../../hooks/use-theme'
6+
import { safeOpen } from '../../utils/open-url'
7+
import { Button } from '../button'
8+
9+
import type { ChatTheme } from '../../types/theme-system'
10+
import type { ToolRenderConfig } from './types'
11+
import type { RenderUIButtonWidget } from '@codebuff/common/tools/params/tool/render-ui'
12+
13+
type RenderUIButtonVariant = NonNullable<RenderUIButtonWidget['variant']>
14+
15+
const isRenderUIButtonWidget = (
16+
widget: unknown,
17+
): widget is RenderUIButtonWidget => {
18+
if (widget === null || typeof widget !== 'object') {
19+
return false
20+
}
21+
22+
const candidate = widget as Partial<RenderUIButtonWidget>
23+
return (
24+
candidate.type === 'button' &&
25+
typeof candidate.text === 'string' &&
26+
candidate.text.trim().length > 0 &&
27+
typeof candidate.link === 'string' &&
28+
candidate.link.trim().length > 0 &&
29+
(candidate.variant === undefined ||
30+
candidate.variant === 'primary' ||
31+
candidate.variant === 'secondary')
32+
)
33+
}
34+
35+
const getButtonColors = (
36+
theme: ChatTheme,
37+
variant: RenderUIButtonVariant,
38+
isHovered: boolean,
39+
status: 'idle' | 'opened' | 'failed',
40+
) => {
41+
if (status === 'failed') {
42+
return {
43+
backgroundColor: theme.surface,
44+
foregroundColor: theme.error,
45+
}
46+
}
47+
48+
if (status === 'opened') {
49+
return {
50+
backgroundColor: theme.surface,
51+
foregroundColor: theme.success,
52+
}
53+
}
54+
55+
if (variant === 'secondary') {
56+
return {
57+
backgroundColor: isHovered ? theme.surfaceHover : theme.surface,
58+
foregroundColor: theme.foreground,
59+
}
60+
}
61+
62+
return {
63+
backgroundColor: theme.primary,
64+
foregroundColor: theme.name === 'dark' ? '#111827' : '#ffffff',
65+
}
66+
}
67+
68+
const RenderUIButton = ({ widget }: { widget: RenderUIButtonWidget }) => {
69+
const theme = useTheme()
70+
const [isHovered, setIsHovered] = useState(false)
71+
const [status, setStatus] = useState<'idle' | 'opened' | 'failed'>('idle')
72+
const variant = widget.variant ?? 'primary'
73+
const { backgroundColor, foregroundColor } = getButtonColors(
74+
theme,
75+
variant,
76+
isHovered,
77+
status,
78+
)
79+
80+
const handleClick = useCallback(async () => {
81+
const opened = await safeOpen(widget.link)
82+
setStatus(opened ? 'opened' : 'failed')
83+
}, [widget.link])
84+
85+
const statusText =
86+
status === 'opened'
87+
? 'Opened'
88+
: status === 'failed'
89+
? `Could not open: ${widget.link}`
90+
: ''
91+
92+
return (
93+
<box
94+
style={{
95+
flexDirection: 'row',
96+
alignItems: 'center',
97+
gap: statusText ? 1 : 0,
98+
}}
99+
>
100+
<Button
101+
onClick={handleClick}
102+
onMouseOver={() => setIsHovered(true)}
103+
onMouseOut={() => setIsHovered(false)}
104+
style={{
105+
backgroundColor,
106+
paddingLeft: 1,
107+
paddingRight: 1,
108+
}}
109+
>
110+
<text>
111+
<span
112+
fg={foregroundColor}
113+
attributes={isHovered ? TextAttributes.BOLD : undefined}
114+
>
115+
{widget.text}
116+
</span>
117+
</text>
118+
</Button>
119+
<text style={{ wrapMode: 'word' }}>
120+
<span fg={status === 'failed' ? theme.error : theme.muted}>
121+
{statusText}
122+
</span>
123+
</text>
124+
</box>
125+
)
126+
}
127+
128+
export const RenderUIComponent = defineToolComponent({
129+
toolName: 'render_ui',
130+
131+
render(toolBlock): ToolRenderConfig {
132+
const widget = toolBlock.input?.widget
133+
134+
if (!isRenderUIButtonWidget(widget)) {
135+
return { content: null }
136+
}
137+
138+
return {
139+
content: <RenderUIButton widget={widget} />,
140+
collapsedPreview: `${widget.text} -> ${widget.link}`,
141+
}
142+
},
143+
})

common/src/templates/initial-agents-dir/types/tools.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type ToolName =
1616
| 'read_docs'
1717
| 'read_files'
1818
| 'read_subtree'
19+
| 'render_ui'
1920
| 'run_file_change_hooks'
2021
| 'run_terminal_command'
2122
| 'set_messages'
@@ -48,6 +49,7 @@ export interface ToolParamsMap {
4849
read_docs: ReadDocsParams
4950
read_files: ReadFilesParams
5051
read_subtree: ReadSubtreeParams
52+
render_ui: RenderUiParams
5153
run_file_change_hooks: RunFileChangeHooksParams
5254
run_terminal_command: RunTerminalCommandParams
5355
set_messages: SetMessagesParams
@@ -231,6 +233,23 @@ export interface ReadSubtreeParams {
231233
maxTokens?: number
232234
}
233235

236+
/**
237+
* Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link.
238+
*/
239+
export interface RenderUiParams {
240+
/** The UI widget to render. */
241+
widget: {
242+
/** Widget type. Currently, the only supported widget is button. */
243+
type: 'button'
244+
/** Short button label shown to the user. */
245+
text: string
246+
/** The http:// or https:// URL to open when the user clicks the button. */
247+
link: string
248+
/** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */
249+
variant?: 'primary' | 'secondary'
250+
}
251+
}
252+
234253
/**
235254
* Parameters for run_file_change_hooks tool
236255
*/

common/src/tools/compile-tool-definitions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,13 @@ function jsonSchemaToTypeScript(schema: any): string {
9393
* Gets TypeScript type from JSON Schema property
9494
*/
9595
function getTypeFromJsonSchema(prop: any): string {
96+
if ('const' in prop) {
97+
return JSON.stringify(prop.const)
98+
}
99+
96100
if (prop.type === 'string') {
97101
if (prop.enum) {
98-
return prop.enum.map((v: string) => `"${v}"`).join(' | ')
102+
return prop.enum.map((v: string) => JSON.stringify(v)).join(' | ')
99103
}
100104
return 'string'
101105
}

common/src/tools/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const TOOLS_WHICH_WONT_FORCE_NEXT_STEP = [
1414
'add_message',
1515
'update_subgoal',
1616
'create_plan',
17+
'render_ui',
1718
'suggest_followups',
1819
'task_completed',
1920
]
@@ -37,6 +38,7 @@ export const toolNames = [
3738
'read_docs',
3839
'read_files',
3940
'read_subtree',
41+
'render_ui',
4042
'run_file_change_hooks',
4143
'run_terminal_command',
4244
'set_messages',
@@ -69,6 +71,7 @@ export const publishedTools = [
6971
'read_docs',
7072
'read_files',
7173
'read_subtree',
74+
'render_ui',
7275
'run_file_change_hooks',
7376
'run_terminal_command',
7477
'set_messages',

0 commit comments

Comments
 (0)