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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ const definition = {
model: 'google/gemini-3.1-pro-preview',
providerOptions: {},
}),
id: 'base2-gemini-no-editor-evals',
displayName: 'Buffy the Gemini Evals Orchestrator',
id: 'base2-gemini-evals',
displayName: 'Buffy the Gemini Orchestrator',
}

export default definition
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
95 changes: 59 additions & 36 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ import { useFreebuffModelStore } from '../state/freebuff-model-store'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
import { useTheme } from '../hooks/use-theme'
import {
nextSelectableFreebuffModelId,
resolveFreebuffModelCommitTarget,
} from '../utils/freebuff-model-navigation'
import { nextFreebuffModelId } from '../utils/freebuff-model-navigation'

import type { KeyEvent } from '@opentui/core'

Expand Down Expand Up @@ -124,11 +121,17 @@ export const FreebuffModelSelector: React.FC = () => {
// when the user's selection moves between queues. The tagline is shown
// inline with the name now, so it's no longer part of this slot.
const hintWidth = useMemo(
() => Math.max('No wait'.length, '999 ahead'.length),
() =>
Math.max(
'No wait'.length,
'999 ahead'.length,
'Used today'.length,
'Limit used'.length,
),
[],
)

// Decide row vs column layout based on whether both buttons actually fit
// Decide row vs column layout based on whether the buttons actually fit
// side-by-side. Each button's inner text is
// "● {displayName} · {tagline} · {hours} {hint}",
// plus 2 cols of border and 2 cols of padding. Buttons are separated by a
Expand Down Expand Up @@ -157,16 +160,28 @@ export const FreebuffModelSelector: React.FC = () => {
// on it. On the landing screen (status 'none'), nothing is committed yet,
// so picking the focused model is always a real action (first join).
const committedModelId = session?.status === 'queued' ? session.model : null
const rateLimitsByModel =
session && 'rateLimitsByModel' in session
? session.rateLimitsByModel
: undefined
const isJoinable = useCallback(
(modelId: string) => {
if (!isFreebuffModelAvailable(modelId, new Date(now))) return false
const rateLimit = rateLimitsByModel?.[modelId]
return !rateLimit || rateLimit.recentCount < rateLimit.limit
},
[now, rateLimitsByModel],
)

const pick = useCallback(
(modelId: string) => {
if (pending) return
if (modelId === committedModelId) return
if (!isFreebuffModelAvailable(modelId, new Date(now))) return
if (!isJoinable(modelId)) return
setPending(modelId)
joinFreebuffQueue(modelId).finally(() => setPending(null))
},
[pending, committedModelId, now],
[pending, committedModelId, isJoinable],
)

// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
Expand All @@ -185,32 +200,23 @@ export const FreebuffModelSelector: React.FC = () => {
name === 'return' || name === 'enter' || name === 'space'
if (!isForward && !isBackward && !isCommit) return
if (isCommit) {
const targetId = resolveFreebuffModelCommitTarget({
focusedId,
selectedId: selectedModel,
committedId: committedModelId,
isSelectable: (modelId) =>
isFreebuffModelAvailable(modelId, new Date(now)),
})
if (targetId) {
if (isJoinable(focusedId) && focusedId !== committedModelId) {
key.preventDefault?.()
pick(targetId)
pick(focusedId)
}
return
}
const targetId = nextSelectableFreebuffModelId({
const targetId = nextFreebuffModelId({
modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id),
focusedId,
direction: isForward ? 'forward' : 'backward',
isSelectable: (modelId) =>
isFreebuffModelAvailable(modelId, new Date(now)),
})
if (targetId) {
key.preventDefault?.()
setFocusedId(targetId)
}
},
[pending, pick, focusedId, selectedModel, committedModelId, now],
[pending, pick, focusedId, committedModelId, isJoinable],
),
)

Expand All @@ -233,32 +239,47 @@ export const FreebuffModelSelector: React.FC = () => {
// 'Selected' means the dot is filled and the label is bold. On the
// landing screen ('none') this tracks the pre-focused pick; on the
// queued screen it tracks the model the server has us on. Either
// way, selectedModel is the safe fallback if focus ever lands on a
// closed row (for example when deployment hours change).
// way, selectedModel marks the user's current preference even if
// focus has moved to a different row.
const isSelected = model.id === selectedModel
const isHovered = hoveredId === model.id
const isFocused = focusedId === model.id && !isSelected
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
const indicator = isSelected ? '●' : '○'
const indicatorColor = isSelected ? theme.primary : theme.muted
const rateLimit = rateLimitsByModel?.[model.id]
const isQuotaExhausted =
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
const canJoin = isAvailable && !isQuotaExhausted
const indicator = isSelected ? '●' : isFocused ? '›' : '○'
const indicatorColor = isSelected
? theme.primary
: isFocused
? theme.foreground
: theme.muted
const labelColor =
isSelected && isAvailable ? theme.foreground : theme.muted
(isSelected || isFocused) && canJoin
? theme.foreground
: theme.muted
// Clickable whenever picking would actually do something — i.e.
// anything except re-picking the queue we're already in.
const interactable =
!pending && isAvailable && model.id !== committedModelId
!pending && canJoin && model.id !== committedModelId
const ahead = aheadByModel?.[model.id]
const hint = !isAvailable
? 'Closed'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`
: isQuotaExhausted
? model.id === FREEBUFF_GEMINI_PRO_MODEL_ID
? 'Used today'
: 'Limit used'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`
const hintColor = canJoin ? theme.muted : theme.secondary

const borderColor = isSelected
? theme.primary
: (isFocused || isHovered) && interactable
: isFocused || isHovered
? theme.foreground
: theme.border

Expand All @@ -267,7 +288,7 @@ export const FreebuffModelSelector: React.FC = () => {
key={model.id}
onClick={() => {
setFocusedId(model.id)
if (isAvailable) pick(model.id)
if (canJoin) pick(model.id)
}}
onMouseOver={() => interactable && setHoveredId(model.id)}
onMouseOut={() =>
Expand All @@ -286,7 +307,9 @@ export const FreebuffModelSelector: React.FC = () => {
<span
fg={labelColor}
attributes={
isSelected ? TextAttributes.BOLD : TextAttributes.NONE
isSelected || isFocused
? TextAttributes.BOLD
: TextAttributes.NONE
}
>
{model.displayName}
Expand All @@ -295,7 +318,7 @@ export const FreebuffModelSelector: React.FC = () => {
{model.availability === 'deployment_hours' && (
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
)}
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
<span fg={hintColor}> {hint.padEnd(hintWidth)}</span>
</text>
</Button>
)
Expand Down
49 changes: 49 additions & 0 deletions cli/src/components/tools/__tests__/gravity-index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'

import { getGravityIndexDescription } from '../gravity-index'

describe('getGravityIndexDescription', () => {
test('describes search queries', () => {
expect(
getGravityIndexDescription({
action: 'search',
query: 'transactional email for a Next.js app',
}),
).toBe('Searching transactional email for a Next.js app')
})

test('describes browse category and keyword', () => {
expect(
getGravityIndexDescription({
action: 'browse',
category: 'Email',
q: 'send',
}),
).toBe('Browsing Email for send')
})

test('describes service detail lookups', () => {
expect(
getGravityIndexDescription({
action: 'get_service',
slug: 'sendgrid',
}),
).toBe('Getting sendgrid')
})

test('describes completed integration reports', () => {
expect(
getGravityIndexDescription({
action: 'report_integration',
integrated_slug: 'sendgrid',
}),
).toBe('Reporting sendgrid integration')
})

test('uses fallback text for unknown input', () => {
expect(getGravityIndexDescription({ action: 'unknown' })).toBe(
'Using service catalog',
)
expect(getGravityIndexDescription(null)).toBe('Using service catalog')
})
})
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()
})
})
Loading
Loading