diff --git a/.agents/base2/base2.ts b/.agents/base2/base2.ts index 6e099c71d..ccd262aba 100644 --- a/.agents/base2/base2.ts +++ b/.agents/base2/base2.ts @@ -51,6 +51,7 @@ export function createBase2( 'read_files', 'read_subtree', !isFast && !isLite && 'write_todos', + !isLite && 'suggest_followups', 'str_replace', 'write_file', 'ask_user', @@ -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 ]` @@ -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 && @@ -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')}` } @@ -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') } diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts index fe59c7527..4d47cc8c4 100644 --- a/.agents/types/tools.ts +++ b/.agents/types/tools.ts @@ -19,6 +19,7 @@ export type ToolName = | 'set_output' | 'spawn_agents' | 'str_replace' + | 'suggest_followups' | 'task_completed' | 'think_deeply' | 'web_search' @@ -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 @@ -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 diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 6492a60b9..6fbfea5d4 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -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, diff --git a/cli/src/components/tools/registry.ts b/cli/src/components/tools/registry.ts index 0b72cd1a1..109889690 100644 --- a/cli/src/components/tools/registry.ts +++ b/cli/src/components/tools/registry.ts @@ -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' @@ -33,6 +34,7 @@ const toolComponentRegistry = new Map([ [ReadSubtreeComponent.toolName, ReadSubtreeComponent], [WriteTodosComponent.toolName, WriteTodosComponent], [StrReplaceComponent.toolName, StrReplaceComponent], + [SuggestFollowupsComponent.toolName, SuggestFollowupsComponent], [WriteFileComponent.toolName, WriteFileComponent], [TaskCompleteComponent.toolName, TaskCompleteComponent], ]) diff --git a/cli/src/components/tools/suggest-followups.tsx b/cli/src/components/tools/suggest-followups.tsx new file mode 100644 index 000000000..f62f0a481 --- /dev/null +++ b/cli/src/components/tools/suggest-followups.tsx @@ -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 ( + + + {isClicked ? '✓' : '→'} + + {' '}{hasLabel ? labelText : promptText} + + {hasLabel && promptText && ( + + {' · '}{promptText} + + )} + + + ) +} + +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() + + return ( + + + Next steps: + + {followups.map((followup, index) => ( + + ))} + + ) +} + +/** + * 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: ( + + ), + } + }, +}) diff --git a/cli/src/state/chat-store.ts b/cli/src/state/chat-store.ts index 493b2f7d9..3d22d26d8 100644 --- a/cli/src/state/chat-store.ts +++ b/cli/src/state/chat-store.ts @@ -74,6 +74,20 @@ export type PendingBashMessage = { addedToHistory?: boolean } +export type SuggestedFollowup = { + prompt: string + label?: string +} + +export type SuggestedFollowupsState = { + /** The tool call ID that created these followups */ + toolCallId: string + /** The list of followup suggestions */ + followups: SuggestedFollowup[] + /** Set of indices that have been clicked */ + clickedIndices: Set +} + export type ChatStoreState = { messages: ChatMessage[] streamingAgents: Set @@ -98,6 +112,7 @@ export type ChatStoreState = { askUserState: AskUserState pendingImages: PendingImage[] pendingBashMessages: PendingBashMessage[] + suggestedFollowups: SuggestedFollowupsState | null } type ChatStoreActions = { @@ -143,6 +158,8 @@ type ChatStoreActions = { ) => void removePendingBashMessage: (id: string) => void clearPendingBashMessages: () => void + setSuggestedFollowups: (state: SuggestedFollowupsState | null) => void + markFollowupClicked: (index: number) => void reset: () => void } @@ -172,6 +189,7 @@ const initialState: ChatStoreState = { askUserState: null, pendingImages: [], pendingBashMessages: [], + suggestedFollowups: null, } export const useChatStore = create()( @@ -382,6 +400,18 @@ export const useChatStore = create()( state.pendingBashMessages = [] }), + setSuggestedFollowups: (suggestedFollowups) => + set((state) => { + state.suggestedFollowups = suggestedFollowups + }), + + markFollowupClicked: (index) => + set((state) => { + if (state.suggestedFollowups) { + state.suggestedFollowups.clickedIndices.add(index) + } + }), + reset: () => set((state) => { state.messages = initialState.messages.slice() @@ -409,6 +439,7 @@ export const useChatStore = create()( state.askUserState = initialState.askUserState state.pendingImages = [] state.pendingBashMessages = [] + state.suggestedFollowups = null }), })), ) diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index fe59c7527..4d47cc8c4 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -19,6 +19,7 @@ export type ToolName = | 'set_output' | 'spawn_agents' | 'str_replace' + | 'suggest_followups' | 'task_completed' | 'think_deeply' | 'web_search' @@ -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 @@ -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 diff --git a/common/src/tools/constants.ts b/common/src/tools/constants.ts index 7435b4edc..f03af6e04 100644 --- a/common/src/tools/constants.ts +++ b/common/src/tools/constants.ts @@ -14,6 +14,7 @@ export const TOOLS_WHICH_WONT_FORCE_NEXT_STEP = [ 'add_message', 'update_subgoal', 'create_plan', + 'suggest_followups', 'task_completed', ] @@ -40,6 +41,7 @@ export const toolNames = [ 'spawn_agents', 'spawn_agent_inline', 'str_replace', + 'suggest_followups', 'task_completed', 'think_deeply', 'update_subgoal', @@ -66,6 +68,7 @@ export const publishedTools = [ 'set_output', 'spawn_agents', 'str_replace', + 'suggest_followups', 'task_completed', 'think_deeply', 'web_search', diff --git a/common/src/tools/list.ts b/common/src/tools/list.ts index d51ae812c..1d1cadaaa 100644 --- a/common/src/tools/list.ts +++ b/common/src/tools/list.ts @@ -22,6 +22,7 @@ import { setOutputParams } from './params/tool/set-output' import { spawnAgentInlineParams } from './params/tool/spawn-agent-inline' import { spawnAgentsParams } from './params/tool/spawn-agents' import { strReplaceParams } from './params/tool/str-replace' +import { suggestFollowupsParams } from './params/tool/suggest-followups' import { taskCompletedParams } from './params/tool/task-completed' import { thinkDeeplyParams } from './params/tool/think-deeply' import { updateSubgoalParams } from './params/tool/update-subgoal' @@ -55,6 +56,7 @@ export const toolParams = { spawn_agents: spawnAgentsParams, spawn_agent_inline: spawnAgentInlineParams, str_replace: strReplaceParams, + suggest_followups: suggestFollowupsParams, task_completed: taskCompletedParams, think_deeply: thinkDeeplyParams, update_subgoal: updateSubgoalParams, diff --git a/common/src/tools/params/tool/suggest-followups.ts b/common/src/tools/params/tool/suggest-followups.ts new file mode 100644 index 000000000..5a03cff1c --- /dev/null +++ b/common/src/tools/params/tool/suggest-followups.ts @@ -0,0 +1,88 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const toolName = 'suggest_followups' +const endsAgentStep = false + +const followupSchema = z.object({ + prompt: z + .string() + .describe('The full prompt text to send as a user message when clicked'), + label: z + .string() + .optional() + .describe( + 'Short display label for the card (defaults to truncated prompt if not provided)', + ), +}) + +export type SuggestFollowup = z.infer + +const inputSchema = z + .object({ + followups: z + .array(followupSchema) + .min(1, 'Must provide at least one followup') + .describe( + 'List of suggested followup prompts the user can click to send', + ), + }) + .describe( + `Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.`, + ) + +const outputSchema = z.object({ + message: z.string(), +}) + +const description = ` +Suggest clickable followup prompts to the user. When the user clicks a suggestion, it sends that prompt as a new user message. + +Use this tool after completing a task to suggest what the user might want to do next. Good suggestions include: +- Alternatives to the latest implementation like "Cache the data to local storage instead" +- Related features like "Add a hover card to show the data from the state" +- Cleanup opportunities like "Refactor app.ts into multiple files" +- Testing suggestions like "Add unit tests for this change" +- "Continue with the next step" - when there are more steps in a plan + +Don't include suggestions like: +- "Commit these changes" +- "Test x" without saying how you would test the changes (unit test, script, or something else?). Remember, this is a prompt for the assistant to do. Don't suggest manual testing that the user would have to do. + +Try to make different suggestions than you did in past steps. That's because users can still click previous suggestions if they want to. + +Aim for around 3 suggestions. The suggestions persist and remain clickable, with clicked ones visually updated to show they were used. + +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + followups: [ + { + prompt: 'Continue with the next step', + label: 'Continue', + }, + { + prompt: 'Add unit tests for the new UserService class', + label: 'Add tests', + }, + { + prompt: 'Refactor the authentication logic into a separate module', + label: 'Refactor auth', + }, + ], + }, + endsAgentStep, +})} +`.trim() + +export const suggestFollowupsParams = { + toolName, + endsAgentStep, + description, + inputSchema, + outputSchema: jsonToolResultSchema(outputSchema), +} satisfies $ToolParams diff --git a/packages/agent-runtime/src/tools/handlers/list.ts b/packages/agent-runtime/src/tools/handlers/list.ts index 07cac78ec..4c5fb752c 100644 --- a/packages/agent-runtime/src/tools/handlers/list.ts +++ b/packages/agent-runtime/src/tools/handlers/list.ts @@ -19,6 +19,7 @@ import { handleSetOutput } from './tool/set-output' import { handleSpawnAgentInline } from './tool/spawn-agent-inline' import { handleSpawnAgents } from './tool/spawn-agents' import { handleStrReplace } from './tool/str-replace' +import { handleSuggestFollowups } from './tool/suggest-followups' import { handleTaskCompleted } from './tool/task-completed' import { handleThinkDeeply } from './tool/think-deeply' import { handleUpdateSubgoal } from './tool/update-subgoal' @@ -60,6 +61,7 @@ export const codebuffToolHandlers = { spawn_agents: handleSpawnAgents, spawn_agent_inline: handleSpawnAgentInline, str_replace: handleStrReplace, + suggest_followups: handleSuggestFollowups, task_completed: handleTaskCompleted, think_deeply: handleThinkDeeply, update_subgoal: handleUpdateSubgoal, diff --git a/packages/agent-runtime/src/tools/handlers/tool/suggest-followups.ts b/packages/agent-runtime/src/tools/handlers/tool/suggest-followups.ts new file mode 100644 index 000000000..e973a317e --- /dev/null +++ b/packages/agent-runtime/src/tools/handlers/tool/suggest-followups.ts @@ -0,0 +1,25 @@ +import type { CodebuffToolHandlerFunction } from '../handler-function-type' +import type { + CodebuffToolCall, + CodebuffToolOutput, +} from '@codebuff/common/tools/list' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export const handleSuggestFollowups = (async (params: { + previousToolCallFinished: Promise + toolCall: CodebuffToolCall<'suggest_followups'> + logger: Logger +}): Promise<{ output: CodebuffToolOutput<'suggest_followups'> }> => { + const { previousToolCallFinished, toolCall, logger } = params + const { followups } = toolCall.input + + logger.debug( + { + followupCount: followups.length, + }, + 'Suggested followups', + ) + + await previousToolCallFinished + return { output: [{ type: 'json', value: { message: 'Followups suggested!' } }] } +}) satisfies CodebuffToolHandlerFunction<'suggest_followups'>