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
10 changes: 6 additions & 4 deletions .agents/base2/base2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function createBase2(
'read_files',
'read_subtree',
!isFast && !isLite && 'write_todos',
!isLite && 'suggest_followups',
'str_replace',
'write_file',
'ask_user',
Expand Down Expand Up @@ -172,7 +173,7 @@ ${buildArray(

[ You spawn one more code-searcher and file-picker ]

[ You read a few other relevant files using the read_files tool ]${isMax ? `\n\n[ You spawn the thinker-best-of-n-opus to help solve a tricky part of the feature ]` : ``}
[ You read a few other relevant files using the read_files tool ]
${
isDefault
? `[ You implement the changes using the editor agent ]`
Expand Down Expand Up @@ -293,8 +294,6 @@ ${buildArray(
`- For any task requiring 3+ steps, use the write_todos tool to write out your step-by-step implementation plan. Include ALL of the applicable tasks in the list.${isFast ? '' : ' You should include a step to review the changes after you have implemented the changes.'}:${hasNoValidation ? '' : ' You should include at least one step to validate/test your changes: be specific about whether to typecheck, run tests, run lints, etc.'} You may be able to do reviewing and validation in parallel in the same step. Skip write_todos for simple tasks like quick edits or answering questions.`,
isDefault &&
`- For complex problems, spawn the thinker agent to help find the best solution, or when the user asks you to think about a problem.`,
isMax &&
`- Important: Spawn the thinker-best-of-n-opus to help find the best solution before implementing changes, or especially when the user asks you to think about a problem.`,
isLite &&
'- IMPORTANT: You must spawn the editor-gpt-5 agent to implement the changes after you have gathered all the context you need. This agent will do the best job of implementing the changes so you must spawn it for all changes. Do not pass any prompt or params to the editor agent when spawning it. It will make its own best choices of what to do.',
isDefault &&
Expand All @@ -310,6 +309,8 @@ ${buildArray(
!hasNoValidation &&
`- Test your changes by running appropriate validation commands for the project (e.g. typechecks, tests, lints, etc.). Try to run all appropriate commands in parallel. ${isMax ? ' Typecheck and test the specific area of the project that you are editing *AND* then typecheck and test the entire project if necessary.' : ' If you can, only test the area of the project that you are editing, rather than the entire project.'} You may have to explore the project to find the appropriate commands. Don't skip this step!`,
`- Inform the user that you have completed the task in one sentence or a few short bullet points.${isSonnet ? " Don't create any markdown summary files or example documentation files, unless asked by the user." : ''}`,
!isLite &&
`- After successfully completing an implementation, use the suggest_followups tool to suggest ~3 next steps the user might want to take (e.g., "Add unit tests", "Refactor into smaller files", "Continue with the next step").`,
).join('\n')}`
}

Expand All @@ -331,10 +332,11 @@ function buildImplementationStepPrompt({
`Keep working until the user's request is completely satisfied${!hasNoValidation ? ' and validated' : ''}, or until you require more information from the user.`,
isMax &&
`You must spawn the 'editor-multi-prompt' agent to implement code changes, since it will generate the best code changes.`,
isMax && 'Spawn the thinker-best-of-n-opus to solve complex problems.',
(isDefault || isMax) &&
'Spawn code-reviewer-opus to review the changes after you have implemented the changes and in parallel with typechecking or testing.',
`After completing the user request, summarize your changes in a sentence${isFast ? '' : ' or a few short bullet points'}.${isSonnet ? " Don't create any summary markdown files or example documentation files, unless asked by the user." : ''} Don't repeat yourself, especially if you have already concluded and summarized the changes in a previous step -- just end your turn.`,
!isFast &&
`After a successful implementation, use the suggest_followups tool to suggest around 3 next steps the user might want to take.`,
).join('\n')
}

Expand Down
15 changes: 15 additions & 0 deletions .agents/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type ToolName =
| 'set_output'
| 'spawn_agents'
| 'str_replace'
| 'suggest_followups'
| 'task_completed'
| 'think_deeply'
| 'web_search'
Expand Down Expand Up @@ -46,6 +47,7 @@ export interface ToolParamsMap {
set_output: SetOutputParams
spawn_agents: SpawnAgentsParams
str_replace: StrReplaceParams
suggest_followups: SuggestFollowupsParams
task_completed: TaskCompletedParams
think_deeply: ThinkDeeplyParams
web_search: WebSearchParams
Expand Down Expand Up @@ -242,6 +244,19 @@ export interface StrReplaceParams {
}[]
}

/**
* Suggest clickable followup prompts to the user.
*/
export interface SuggestFollowupsParams {
/** List of suggested followup prompts the user can click to send */
followups: {
/** The full prompt text to send as a user message when clicked */
prompt: string
/** Short display label for the card (defaults to truncated prompt if not provided) */
label?: string
}[]
}

/**
* Signal that the task is complete. Use this tool when:
- The user's request is completely fulfilled
Expand Down
62 changes: 62 additions & 0 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,68 @@ export const Chat = ({

sendMessageRef.current = sendMessage

// Handle followup suggestion clicks
useEffect(() => {
const handleFollowupClick = (event: Event) => {
const customEvent = event as CustomEvent<{ prompt: string; index: number }>
const { prompt, index } = customEvent.detail

// Mark this followup as clicked
useChatStore.getState().markFollowupClicked(index)

// Send the followup prompt as a user message
ensureQueueActiveBeforeSubmit()
void routeUserPrompt({
abortControllerRef,
agentMode,
inputRef,
inputValue: prompt,
isChainInProgressRef,
isStreaming,
logoutMutation,
streamMessageIdRef,
addToQueue,
clearMessages,
saveToHistory,
scrollToLatest,
sendMessage,
setCanProcessQueue,
setInputFocused,
setInputValue,
setIsAuthenticated,
setMessages,
setUser,
stopStreaming,
})
}

globalThis.addEventListener('codebuff:send-followup', handleFollowupClick)
return () => {
globalThis.removeEventListener('codebuff:send-followup', handleFollowupClick)
}
}, [
abortControllerRef,
agentMode,
inputRef,
isChainInProgressRef,
isStreaming,
logoutMutation,
streamMessageIdRef,
addToQueue,
clearMessages,
saveToHistory,
scrollToLatest,
sendMessage,
setCanProcessQueue,
setInputFocused,
setInputValue,
setIsAuthenticated,
setMessages,
setUser,
stopStreaming,
ensureQueueActiveBeforeSubmit,
])

const onSubmitPrompt = useEvent((content: string, mode: AgentMode) => {
return routeUserPrompt({
abortControllerRef,
Expand Down
2 changes: 2 additions & 0 deletions cli/src/components/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ReadFilesComponent } from './read-files'
import { ReadSubtreeComponent } from './read-subtree'
import { RunTerminalCommandComponent } from './run-terminal-command'
import { StrReplaceComponent } from './str-replace'
import { SuggestFollowupsComponent } from './suggest-followups'
import { TaskCompleteComponent } from './task-complete'
import { WriteFileComponent } from './write-file'
import { WriteTodosComponent } from './write-todos'
Expand Down Expand Up @@ -33,6 +34,7 @@ const toolComponentRegistry = new Map<ToolName, ToolComponent>([
[ReadSubtreeComponent.toolName, ReadSubtreeComponent],
[WriteTodosComponent.toolName, WriteTodosComponent],
[StrReplaceComponent.toolName, StrReplaceComponent],
[SuggestFollowupsComponent.toolName, SuggestFollowupsComponent],
[WriteFileComponent.toolName, WriteFileComponent],
[TaskCompleteComponent.toolName, TaskCompleteComponent],
])
Expand Down
216 changes: 216 additions & 0 deletions cli/src/components/tools/suggest-followups.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import React, { useCallback, useState } from 'react'
import { TextAttributes } from '@opentui/core'

import { defineToolComponent } from './types'
import { useTheme } from '../../hooks/use-theme'
import { useChatStore } from '../../state/chat-store'
import { useTerminalDimensions } from '../../hooks/use-terminal-dimensions'

import type { ToolRenderConfig } from './types'
import type { SuggestedFollowup } from '../../state/chat-store'

interface FollowupLineProps {
followup: SuggestedFollowup
index: number
isClicked: boolean
onSendFollowup: (prompt: string, index: number) => void
}

const FollowupLine = ({
followup,
index,
isClicked,
onSendFollowup,
}: FollowupLineProps) => {
const theme = useTheme()
const { terminalWidth } = useTerminalDimensions()
const [isHovered, setIsHovered] = useState(false)

const handleClick = useCallback(() => {
if (isClicked) return
onSendFollowup(followup.prompt, index)
}, [followup.prompt, index, onSendFollowup, isClicked])

const handleMouseOver = useCallback(() => setIsHovered(true), [])
const handleMouseOut = useCallback(() => setIsHovered(false), [])

const hasLabel = Boolean(followup.label)
// "→ " = 2 chars (icon + space), " · " separator = 3 chars, "…" = 1 char
const iconWidth = 2
const separatorWidth = hasLabel ? 3 : 0
const ellipsisWidth = 1
const maxWidth = terminalWidth - 6 // Extra margin for safety

// Build the display text with label and prompt
let labelText = followup.label || ''
let promptText = followup.prompt

// Calculate available space
const availableForContent = maxWidth - iconWidth

if (hasLabel) {
// Show: label · prompt (truncated)
const labelWithSeparator = labelText.length + separatorWidth
const totalLength = labelWithSeparator + promptText.length

if (totalLength > availableForContent) {
// Truncate prompt to fit
const availableForPrompt = availableForContent - labelWithSeparator - ellipsisWidth
if (availableForPrompt > 0) {
promptText = promptText.slice(0, availableForPrompt) + '…'
} else {
// Not enough space for prompt, just show label truncated
promptText = ''
if (labelText.length > availableForContent - ellipsisWidth) {
labelText = labelText.slice(0, availableForContent - ellipsisWidth) + '…'
}
}
}
} else {
// No label, just show prompt (truncated)
if (promptText.length > availableForContent) {
promptText = promptText.slice(0, availableForContent - ellipsisWidth) + '…'
}
}

// Determine colors based on state
const iconColor = isClicked
? theme.success
: isHovered
? theme.primary
: theme.muted
const labelColor = isClicked
? theme.muted
: isHovered
? theme.primary
: theme.foreground
const promptColor = isClicked
? theme.muted
: isHovered
? theme.primary
: theme.muted

return (
<box
onMouseDown={handleClick}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
>
<text selectable={false}>
<span fg={iconColor}>{isClicked ? '✓' : '→'}</span>
<span fg={labelColor} attributes={isHovered ? TextAttributes.UNDERLINE : undefined}>
{' '}{hasLabel ? labelText : promptText}
</span>
{hasLabel && promptText && (
<span fg={promptColor}>
{' · '}{promptText}
</span>
)}
</text>
</box>
)
}

interface SuggestFollowupsItemProps {
toolCallId: string
followups: SuggestedFollowup[]
onSendFollowup: (prompt: string, index: number) => void
}

const SuggestFollowupsItem = ({
toolCallId,
followups,
onSendFollowup,
}: SuggestFollowupsItemProps) => {
const theme = useTheme()
const suggestedFollowups = useChatStore((state) => state.suggestedFollowups)

// Get clicked indices for this specific tool call
const clickedIndices =
suggestedFollowups?.toolCallId === toolCallId
? suggestedFollowups.clickedIndices
: new Set<number>()

return (
<box style={{ flexDirection: 'column' }}>
<text style={{ fg: theme.muted }}>
Next steps:
</text>
{followups.map((followup, index) => (
<FollowupLine
key={`followup-${index}`}
followup={followup}
index={index}
isClicked={clickedIndices.has(index)}
onSendFollowup={onSendFollowup}
/>
))}
</box>
)
}

/**
* UI component for suggest_followups tool.
* Displays clickable cards that send the followup prompt as a user message when clicked.
*/
export const SuggestFollowupsComponent = defineToolComponent({
toolName: 'suggest_followups',

render(toolBlock): ToolRenderConfig {
const { input, toolCallId } = toolBlock

// Extract followups from input
let followups: SuggestedFollowup[] = []

if (Array.isArray(input?.followups)) {
followups = input.followups.filter(
(f: unknown): f is SuggestedFollowup =>
typeof f === 'object' &&
f !== null &&
typeof (f as SuggestedFollowup).prompt === 'string',
)
}

if (followups.length === 0) {
return { content: null }
}

// Store the followups in state for tracking clicks
// This is done via a ref to avoid re-renders during the render phase
const store = useChatStore.getState()
if (
!store.suggestedFollowups ||
store.suggestedFollowups.toolCallId !== toolCallId
) {
// Schedule the state update for after render
setTimeout(() => {
useChatStore.getState().setSuggestedFollowups({
toolCallId,
followups,
clickedIndices: new Set(),
})
}, 0)
}

// The actual click handling is done in chat.tsx via the global handler
// Here we just pass a placeholder that will be replaced
const handleSendFollowup = (prompt: string, index: number) => {
// This gets called from the FollowupCard component
// The actual logic is handled via the global followup handler
const event = new CustomEvent('codebuff:send-followup', {
detail: { prompt, index },
})
globalThis.dispatchEvent(event)
}

return {
content: (
<SuggestFollowupsItem
toolCallId={toolCallId}
followups={followups}
onSendFollowup={handleSendFollowup}
/>
),
}
},
})
Loading