Skip to content

Commit e1f128e

Browse files
committed
Add render UI button tool
1 parent 5b1cbe9 commit e1f128e

13 files changed

Lines changed: 392 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
@@ -70,6 +70,7 @@ export function createBase2(
7070
'read_subtree',
7171
!isFast && 'write_todos',
7272
!isFast && !noAskUser && 'suggest_followups',
73+
!isFast && 'render_ui',
7374
'str_replace',
7475
'write_file',
7576
!isFree && 'propose_str_replace',

agents/types/tools.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type ToolName =
1717
| 'read_docs'
1818
| 'read_files'
1919
| 'read_subtree'
20+
| 'render_ui'
2021
| 'run_file_change_hooks'
2122
| 'run_terminal_command'
2223
| 'set_messages'
@@ -50,6 +51,7 @@ export interface ToolParamsMap {
5051
read_docs: ReadDocsParams
5152
read_files: ReadFilesParams
5253
read_subtree: ReadSubtreeParams
54+
render_ui: RenderUiParams
5355
run_file_change_hooks: RunFileChangeHooksParams
5456
run_terminal_command: RunTerminalCommandParams
5557
set_messages: SetMessagesParams
@@ -274,6 +276,23 @@ export interface ReadSubtreeParams {
274276
maxTokens?: number
275277
}
276278

279+
/**
280+
* Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link.
281+
*/
282+
export interface RenderUiParams {
283+
/** The UI widget to render. */
284+
widget: {
285+
/** Widget type. Currently, the only supported widget is button. */
286+
type: 'button'
287+
/** Short button label shown to the user. */
288+
text: string
289+
/** The http:// or https:// URL to open when the user clicks the button. */
290+
link: string
291+
/** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */
292+
variant?: 'primary' | 'secondary'
293+
}
294+
}
295+
277296
/**
278297
* Parameters for run_file_change_hooks tool
279298
*/
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
@@ -17,6 +17,7 @@ export type ToolName =
1717
| 'read_docs'
1818
| 'read_files'
1919
| 'read_subtree'
20+
| 'render_ui'
2021
| 'run_file_change_hooks'
2122
| 'run_terminal_command'
2223
| 'set_messages'
@@ -50,6 +51,7 @@ export interface ToolParamsMap {
5051
read_docs: ReadDocsParams
5152
read_files: ReadFilesParams
5253
read_subtree: ReadSubtreeParams
54+
render_ui: RenderUiParams
5355
run_file_change_hooks: RunFileChangeHooksParams
5456
run_terminal_command: RunTerminalCommandParams
5557
set_messages: SetMessagesParams
@@ -274,6 +276,23 @@ export interface ReadSubtreeParams {
274276
maxTokens?: number
275277
}
276278

279+
/**
280+
* Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link.
281+
*/
282+
export interface RenderUiParams {
283+
/** The UI widget to render. */
284+
widget: {
285+
/** Widget type. Currently, the only supported widget is button. */
286+
type: 'button'
287+
/** Short button label shown to the user. */
288+
text: string
289+
/** The http:// or https:// URL to open when the user clicks the button. */
290+
link: string
291+
/** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */
292+
variant?: 'primary' | 'secondary'
293+
}
294+
}
295+
277296
/**
278297
* Parameters for run_file_change_hooks tool
279298
*/

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ function getTypeFromJsonSchema(prop: any): string {
111111
if (prop.const !== undefined) {
112112
return JSON.stringify(prop.const)
113113
}
114+
114115
if (prop.type === 'string') {
115116
if (prop.enum) {
116-
return prop.enum.map((v: string) => `"${v}"`).join(' | ')
117+
return prop.enum.map((v: string) => JSON.stringify(v)).join(' | ')
117118
}
118119
return 'string'
119120
}

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
]
@@ -38,6 +39,7 @@ export const toolNames = [
3839
'read_docs',
3940
'read_files',
4041
'read_subtree',
42+
'render_ui',
4143
'run_file_change_hooks',
4244
'run_terminal_command',
4345
'set_messages',
@@ -71,6 +73,7 @@ export const publishedTools = [
7173
'read_docs',
7274
'read_files',
7375
'read_subtree',
76+
'render_ui',
7477
'run_file_change_hooks',
7578
'run_terminal_command',
7679
'set_messages',

0 commit comments

Comments
 (0)