From 1892a938bd703af0cfe924a9e86bdcb5bd7da958 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 17:22:35 -0800 Subject: [PATCH 1/9] base2-max: deemphasize thinker-best-of-n --- .agents/base2/base2.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.agents/base2/base2.ts b/.agents/base2/base2.ts index 6e099c71d..b6317100a 100644 --- a/.agents/base2/base2.ts +++ b/.agents/base2/base2.ts @@ -172,7 +172,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 +293,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 && @@ -331,7 +329,6 @@ 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.`, From a77a1474b39e023981a2a9b8bf311fab54c6472b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 18:09:36 -0800 Subject: [PATCH 2/9] Initial followup tool impl --- .agents/base2/base2.ts | 5 + .agents/types/tools.ts | 15 ++ cli/src/chat.tsx | 62 +++++++ cli/src/components/tools/registry.ts | 2 + .../components/tools/suggest-followups.tsx | 171 ++++++++++++++++++ cli/src/state/chat-store.ts | 31 ++++ .../initial-agents-dir/types/tools.ts | 15 ++ common/src/tools/constants.ts | 2 + common/src/tools/list.ts | 2 + .../tools/params/tool/suggest-followups.ts | 86 +++++++++ .../agent-runtime/src/tools/handlers/list.ts | 2 + .../tools/handlers/tool/suggest-followups.ts | 25 +++ 12 files changed, 418 insertions(+) create mode 100644 cli/src/components/tools/suggest-followups.tsx create mode 100644 common/src/tools/params/tool/suggest-followups.ts create mode 100644 packages/agent-runtime/src/tools/handlers/tool/suggest-followups.ts diff --git a/.agents/base2/base2.ts b/.agents/base2/base2.ts index b6317100a..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', @@ -308,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')}` } @@ -332,6 +335,8 @@ function buildImplementationStepPrompt({ (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..1bfec1535 --- /dev/null +++ b/cli/src/components/tools/suggest-followups.tsx @@ -0,0 +1,171 @@ +import React, { useCallback } from 'react' + +import { Button } from '../button' +import { defineToolComponent } from './types' +import { useTheme } from '../../hooks/use-theme' +import { useChatStore } from '../../state/chat-store' + +import type { ToolRenderConfig } from './types' +import type { SuggestedFollowup } from '../../state/chat-store' + +interface FollowupCardProps { + followup: SuggestedFollowup + index: number + isClicked: boolean + onSendFollowup: (prompt: string, index: number) => void +} + +const FollowupCard = ({ + followup, + index, + isClicked, + onSendFollowup, +}: FollowupCardProps) => { + const theme = useTheme() + + const handleClick = useCallback(() => { + onSendFollowup(followup.prompt, index) + }, [followup.prompt, index, onSendFollowup]) + + // Use label if provided, otherwise truncate the prompt + const displayLabel = followup.label || truncateText(followup.prompt, 40) + + return ( + + ) +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength - 1) + '…' +} + +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 ( + + Suggested 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..4be0a7788 100644 --- a/common/src/tools/constants.ts +++ b/common/src/tools/constants.ts @@ -40,6 +40,7 @@ export const toolNames = [ 'spawn_agents', 'spawn_agent_inline', 'str_replace', + 'suggest_followups', 'task_completed', 'think_deeply', 'update_subgoal', @@ -66,6 +67,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..8a22c51a7 --- /dev/null +++ b/common/src/tools/params/tool/suggest-followups.ts @@ -0,0 +1,86 @@ +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: +- "Continue with the next step" - when there are more steps in a plan +- Cleanup opportunities like "Refactor app.ts into multiple files" +- Testing suggestions like "Add unit tests for this change" +- Related features like "Add a hover card to show the data from the state" + +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'> From 57abbf23e36772fb44ec372c86b44521c2e0ca8a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 18:42:37 -0800 Subject: [PATCH 3/9] Don't force new turn with suggest_followups --- common/src/tools/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/common/src/tools/constants.ts b/common/src/tools/constants.ts index 4be0a7788..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', ] From ee91e4359f5540d82418137369ad84c2bef2e214 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 19:03:32 -0800 Subject: [PATCH 4/9] Improve followup ui --- .../components/tools/suggest-followups.tsx | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/cli/src/components/tools/suggest-followups.tsx b/cli/src/components/tools/suggest-followups.tsx index 1bfec1535..56a4bd98e 100644 --- a/cli/src/components/tools/suggest-followups.tsx +++ b/cli/src/components/tools/suggest-followups.tsx @@ -1,4 +1,5 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' +import { TextAttributes } from '@opentui/core' import { Button } from '../button' import { defineToolComponent } from './types' @@ -22,43 +23,66 @@ const FollowupCard = ({ onSendFollowup, }: FollowupCardProps) => { const theme = useTheme() + const [isHovered, setIsHovered] = useState(false) const handleClick = useCallback(() => { onSendFollowup(followup.prompt, index) }, [followup.prompt, index, onSendFollowup]) - // Use label if provided, otherwise truncate the prompt - const displayLabel = followup.label || truncateText(followup.prompt, 40) + const handleMouseOver = useCallback(() => setIsHovered(true), []) + const handleMouseOut = useCallback(() => setIsHovered(false), []) + + const hasLabel = Boolean(followup.label) + + // Determine colors based on state + const borderColor = isClicked + ? theme.success + : isHovered + ? theme.primary + : theme.border + const labelColor = isClicked ? theme.muted : theme.secondary + const promptColor = isClicked ? theme.muted : theme.foreground return ( ) } -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) return text - return text.slice(0, maxLength - 1) + '…' -} - interface SuggestFollowupsItemProps { toolCallId: string followups: SuggestedFollowup[] @@ -80,14 +104,19 @@ const SuggestFollowupsItem = ({ : new Set() return ( - - Suggested next steps: + + + Suggested next steps: + {followups.map((followup, index) => ( From 96751770a68d42fe93c3ee4e08771f82754fabaa Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 19:15:42 -0800 Subject: [PATCH 5/9] Tweak prompt for suggestions --- .../tools/params/tool/suggest-followups.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/common/src/tools/params/tool/suggest-followups.ts b/common/src/tools/params/tool/suggest-followups.ts index 8a22c51a7..92b75ebc6 100644 --- a/common/src/tools/params/tool/suggest-followups.ts +++ b/common/src/tools/params/tool/suggest-followups.ts @@ -1,9 +1,6 @@ import z from 'zod/v4' -import { - $getNativeToolCallExampleString, - jsonToolResultSchema, -} from '../utils' +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' import type { $ToolParams } from '../../constants' @@ -13,9 +10,7 @@ const endsAgentStep = false const followupSchema = z.object({ prompt: z .string() - .describe( - 'The full prompt text to send as a user message when clicked', - ), + .describe('The full prompt text to send as a user message when clicked'), label: z .string() .optional() @@ -36,7 +31,7 @@ const inputSchema = z ), }) .describe( - 'Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.', + `Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.`, ) const outputSchema = z.object({ @@ -47,10 +42,15 @@ 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: -- "Continue with the next step" - when there are more steps in a plan +- 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" -- Related features like "Add a hover card to show the data from the state" +- "Continue with the next step" - when there are more steps in a plan + +Don't include suggestions like: +- "Commit these changes" +- "Test the changes" without saying how you would test the changes (unit test, script, or something else?) Aim for around 3 suggestions. The suggestions persist and remain clickable, with clicked ones visually updated to show they were used. From f1c8be1fc3fddd5464839028a3ce175853c05dee Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 19:29:54 -0800 Subject: [PATCH 6/9] Improve layout --- cli/src/components/tools/suggest-followups.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cli/src/components/tools/suggest-followups.tsx b/cli/src/components/tools/suggest-followups.tsx index 56a4bd98e..ca21b95a0 100644 --- a/cli/src/components/tools/suggest-followups.tsx +++ b/cli/src/components/tools/suggest-followups.tsx @@ -5,6 +5,7 @@ import { Button } from '../button' 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' @@ -13,6 +14,7 @@ interface FollowupCardProps { followup: SuggestedFollowup index: number isClicked: boolean + isStacked: boolean onSendFollowup: (prompt: string, index: number) => void } @@ -20,6 +22,7 @@ const FollowupCard = ({ followup, index, isClicked, + isStacked, onSendFollowup, }: FollowupCardProps) => { const theme = useTheme() @@ -53,9 +56,8 @@ const FollowupCard = ({ paddingRight: 2, paddingTop: 0, paddingBottom: 0, - maxWidth: 40, + ...(isStacked ? { width: '100%' } : { flexGrow: 1, flexShrink: 1 }), borderColor, - flexGrow: 1, }} > @@ -89,12 +91,16 @@ interface SuggestFollowupsItemProps { onSendFollowup: (prompt: string, index: number) => void } +// Threshold width to switch between horizontal and stacked layouts +const WIDE_SCREEN_THRESHOLD = 100 + const SuggestFollowupsItem = ({ toolCallId, followups, onSendFollowup, }: SuggestFollowupsItemProps) => { const theme = useTheme() + const { terminalWidth } = useTerminalDimensions() const suggestedFollowups = useChatStore((state) => state.suggestedFollowups) // Get clicked indices for this specific tool call @@ -103,6 +109,9 @@ const SuggestFollowupsItem = ({ ? suggestedFollowups.clickedIndices : new Set() + // Use stacked layout on narrow screens + const isStacked = terminalWidth < WIDE_SCREEN_THRESHOLD + return ( {followups.map((followup, index) => ( @@ -125,6 +133,7 @@ const SuggestFollowupsItem = ({ followup={followup} index={index} isClicked={clickedIndices.has(index)} + isStacked={isStacked} onSendFollowup={onSendFollowup} /> ))} From 6d71747bbe2b568c2d2df318414ab780ee1997fd Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 19:39:35 -0800 Subject: [PATCH 7/9] tweak prompts, don't allow clicking again --- cli/src/components/tools/suggest-followups.tsx | 4 +++- common/src/tools/params/tool/suggest-followups.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/src/components/tools/suggest-followups.tsx b/cli/src/components/tools/suggest-followups.tsx index ca21b95a0..e070acc61 100644 --- a/cli/src/components/tools/suggest-followups.tsx +++ b/cli/src/components/tools/suggest-followups.tsx @@ -29,8 +29,10 @@ const FollowupCard = ({ const [isHovered, setIsHovered] = useState(false) const handleClick = useCallback(() => { + // Don't allow clicking already-selected followups + if (isClicked) return onSendFollowup(followup.prompt, index) - }, [followup.prompt, index, onSendFollowup]) + }, [followup.prompt, index, onSendFollowup, isClicked]) const handleMouseOver = useCallback(() => setIsHovered(true), []) const handleMouseOut = useCallback(() => setIsHovered(false), []) diff --git a/common/src/tools/params/tool/suggest-followups.ts b/common/src/tools/params/tool/suggest-followups.ts index 92b75ebc6..cab41f6aa 100644 --- a/common/src/tools/params/tool/suggest-followups.ts +++ b/common/src/tools/params/tool/suggest-followups.ts @@ -50,7 +50,7 @@ Use this tool after completing a task to suggest what the user might want to do Don't include suggestions like: - "Commit these changes" -- "Test the changes" without saying how you would test the changes (unit test, script, or something else?) +- "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. Aim for around 3 suggestions. The suggestions persist and remain clickable, with clicked ones visually updated to show they were used. From 7a0a654a85e5771431d8c100138d72a7a8e9bc48 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 20:30:08 -0800 Subject: [PATCH 8/9] Go to a much more compact follow up steps UI --- .../components/tools/suggest-followups.tsx | 151 +++++++++--------- 1 file changed, 78 insertions(+), 73 deletions(-) diff --git a/cli/src/components/tools/suggest-followups.tsx b/cli/src/components/tools/suggest-followups.tsx index e070acc61..f62f0a481 100644 --- a/cli/src/components/tools/suggest-followups.tsx +++ b/cli/src/components/tools/suggest-followups.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useState } from 'react' import { TextAttributes } from '@opentui/core' -import { Button } from '../button' import { defineToolComponent } from './types' import { useTheme } from '../../hooks/use-theme' import { useChatStore } from '../../state/chat-store' @@ -10,26 +9,24 @@ import { useTerminalDimensions } from '../../hooks/use-terminal-dimensions' import type { ToolRenderConfig } from './types' import type { SuggestedFollowup } from '../../state/chat-store' -interface FollowupCardProps { +interface FollowupLineProps { followup: SuggestedFollowup index: number isClicked: boolean - isStacked: boolean onSendFollowup: (prompt: string, index: number) => void } -const FollowupCard = ({ +const FollowupLine = ({ followup, index, isClicked, - isStacked, onSendFollowup, -}: FollowupCardProps) => { +}: FollowupLineProps) => { const theme = useTheme() + const { terminalWidth } = useTerminalDimensions() const [isHovered, setIsHovered] = useState(false) const handleClick = useCallback(() => { - // Don't allow clicking already-selected followups if (isClicked) return onSendFollowup(followup.prompt, index) }, [followup.prompt, index, onSendFollowup, isClicked]) @@ -38,52 +35,79 @@ const FollowupCard = ({ 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 borderColor = isClicked + const iconColor = isClicked ? theme.success : isHovered ? theme.primary - : theme.border - const labelColor = isClicked ? theme.muted : theme.secondary - const promptColor = isClicked ? theme.muted : theme.foreground + : theme.muted + const labelColor = isClicked + ? theme.muted + : isHovered + ? theme.primary + : theme.foreground + const promptColor = isClicked + ? theme.muted + : isHovered + ? theme.primary + : theme.muted return ( - + + ) } @@ -93,16 +117,12 @@ interface SuggestFollowupsItemProps { onSendFollowup: (prompt: string, index: number) => void } -// Threshold width to switch between horizontal and stacked layouts -const WIDE_SCREEN_THRESHOLD = 100 - const SuggestFollowupsItem = ({ toolCallId, followups, onSendFollowup, }: SuggestFollowupsItemProps) => { const theme = useTheme() - const { terminalWidth } = useTerminalDimensions() const suggestedFollowups = useChatStore((state) => state.suggestedFollowups) // Get clicked indices for this specific tool call @@ -111,35 +131,20 @@ const SuggestFollowupsItem = ({ ? suggestedFollowups.clickedIndices : new Set() - // Use stacked layout on narrow screens - const isStacked = terminalWidth < WIDE_SCREEN_THRESHOLD - return ( - - - Suggested next steps: + + + Next steps: - - {followups.map((followup, index) => ( - - ))} - + {followups.map((followup, index) => ( + + ))} ) } From f09354636d1f5984a82713c44060bcac8144f6ae Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 10 Dec 2025 20:33:48 -0800 Subject: [PATCH 9/9] Make new suggestions each time --- common/src/tools/params/tool/suggest-followups.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/src/tools/params/tool/suggest-followups.ts b/common/src/tools/params/tool/suggest-followups.ts index cab41f6aa..5a03cff1c 100644 --- a/common/src/tools/params/tool/suggest-followups.ts +++ b/common/src/tools/params/tool/suggest-followups.ts @@ -52,6 +52,8 @@ 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({