Skip to content

Commit 42f2252

Browse files
authored
Followup tool (#392)
1 parent 86f3b10 commit 42f2252

File tree

12 files changed

+467
-4
lines changed

12 files changed

+467
-4
lines changed

.agents/base2/base2.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function createBase2(
5151
'read_files',
5252
'read_subtree',
5353
!isFast && !isLite && 'write_todos',
54+
!isLite && 'suggest_followups',
5455
'str_replace',
5556
'write_file',
5657
'ask_user',
@@ -172,7 +173,7 @@ ${buildArray(
172173
173174
[ You spawn one more code-searcher and file-picker ]
174175
175-
[ 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 ]` : ``}
176+
[ You read a few other relevant files using the read_files tool ]
176177
${
177178
isDefault
178179
? `[ You implement the changes using the editor agent ]`
@@ -293,8 +294,6 @@ ${buildArray(
293294
`- 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.`,
294295
isDefault &&
295296
`- For complex problems, spawn the thinker agent to help find the best solution, or when the user asks you to think about a problem.`,
296-
isMax &&
297-
`- 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.`,
298297
isLite &&
299298
'- 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.',
300299
isDefault &&
@@ -310,6 +309,8 @@ ${buildArray(
310309
!hasNoValidation &&
311310
`- 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!`,
312311
`- 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." : ''}`,
312+
!isLite &&
313+
`- 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").`,
313314
).join('\n')}`
314315
}
315316

@@ -331,10 +332,11 @@ function buildImplementationStepPrompt({
331332
`Keep working until the user's request is completely satisfied${!hasNoValidation ? ' and validated' : ''}, or until you require more information from the user.`,
332333
isMax &&
333334
`You must spawn the 'editor-multi-prompt' agent to implement code changes, since it will generate the best code changes.`,
334-
isMax && 'Spawn the thinker-best-of-n-opus to solve complex problems.',
335335
(isDefault || isMax) &&
336336
'Spawn code-reviewer-opus to review the changes after you have implemented the changes and in parallel with typechecking or testing.',
337337
`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.`,
338+
!isFast &&
339+
`After a successful implementation, use the suggest_followups tool to suggest around 3 next steps the user might want to take.`,
338340
).join('\n')
339341
}
340342

.agents/types/tools.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type ToolName =
1919
| 'set_output'
2020
| 'spawn_agents'
2121
| 'str_replace'
22+
| 'suggest_followups'
2223
| 'task_completed'
2324
| 'think_deeply'
2425
| 'web_search'
@@ -46,6 +47,7 @@ export interface ToolParamsMap {
4647
set_output: SetOutputParams
4748
spawn_agents: SpawnAgentsParams
4849
str_replace: StrReplaceParams
50+
suggest_followups: SuggestFollowupsParams
4951
task_completed: TaskCompletedParams
5052
think_deeply: ThinkDeeplyParams
5153
web_search: WebSearchParams
@@ -242,6 +244,19 @@ export interface StrReplaceParams {
242244
}[]
243245
}
244246

247+
/**
248+
* Suggest clickable followup prompts to the user.
249+
*/
250+
export interface SuggestFollowupsParams {
251+
/** List of suggested followup prompts the user can click to send */
252+
followups: {
253+
/** The full prompt text to send as a user message when clicked */
254+
prompt: string
255+
/** Short display label for the card (defaults to truncated prompt if not provided) */
256+
label?: string
257+
}[]
258+
}
259+
245260
/**
246261
* Signal that the task is complete. Use this tool when:
247262
- The user's request is completely fulfilled

cli/src/chat.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,68 @@ export const Chat = ({
613613

614614
sendMessageRef.current = sendMessage
615615

616+
// Handle followup suggestion clicks
617+
useEffect(() => {
618+
const handleFollowupClick = (event: Event) => {
619+
const customEvent = event as CustomEvent<{ prompt: string; index: number }>
620+
const { prompt, index } = customEvent.detail
621+
622+
// Mark this followup as clicked
623+
useChatStore.getState().markFollowupClicked(index)
624+
625+
// Send the followup prompt as a user message
626+
ensureQueueActiveBeforeSubmit()
627+
void routeUserPrompt({
628+
abortControllerRef,
629+
agentMode,
630+
inputRef,
631+
inputValue: prompt,
632+
isChainInProgressRef,
633+
isStreaming,
634+
logoutMutation,
635+
streamMessageIdRef,
636+
addToQueue,
637+
clearMessages,
638+
saveToHistory,
639+
scrollToLatest,
640+
sendMessage,
641+
setCanProcessQueue,
642+
setInputFocused,
643+
setInputValue,
644+
setIsAuthenticated,
645+
setMessages,
646+
setUser,
647+
stopStreaming,
648+
})
649+
}
650+
651+
globalThis.addEventListener('codebuff:send-followup', handleFollowupClick)
652+
return () => {
653+
globalThis.removeEventListener('codebuff:send-followup', handleFollowupClick)
654+
}
655+
}, [
656+
abortControllerRef,
657+
agentMode,
658+
inputRef,
659+
isChainInProgressRef,
660+
isStreaming,
661+
logoutMutation,
662+
streamMessageIdRef,
663+
addToQueue,
664+
clearMessages,
665+
saveToHistory,
666+
scrollToLatest,
667+
sendMessage,
668+
setCanProcessQueue,
669+
setInputFocused,
670+
setInputValue,
671+
setIsAuthenticated,
672+
setMessages,
673+
setUser,
674+
stopStreaming,
675+
ensureQueueActiveBeforeSubmit,
676+
])
677+
616678
const onSubmitPrompt = useEvent((content: string, mode: AgentMode) => {
617679
return routeUserPrompt({
618680
abortControllerRef,

cli/src/components/tools/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ReadFilesComponent } from './read-files'
66
import { ReadSubtreeComponent } from './read-subtree'
77
import { RunTerminalCommandComponent } from './run-terminal-command'
88
import { StrReplaceComponent } from './str-replace'
9+
import { SuggestFollowupsComponent } from './suggest-followups'
910
import { TaskCompleteComponent } from './task-complete'
1011
import { WriteFileComponent } from './write-file'
1112
import { WriteTodosComponent } from './write-todos'
@@ -33,6 +34,7 @@ const toolComponentRegistry = new Map<ToolName, ToolComponent>([
3334
[ReadSubtreeComponent.toolName, ReadSubtreeComponent],
3435
[WriteTodosComponent.toolName, WriteTodosComponent],
3536
[StrReplaceComponent.toolName, StrReplaceComponent],
37+
[SuggestFollowupsComponent.toolName, SuggestFollowupsComponent],
3638
[WriteFileComponent.toolName, WriteFileComponent],
3739
[TaskCompleteComponent.toolName, TaskCompleteComponent],
3840
])
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import React, { useCallback, useState } from 'react'
2+
import { TextAttributes } from '@opentui/core'
3+
4+
import { defineToolComponent } from './types'
5+
import { useTheme } from '../../hooks/use-theme'
6+
import { useChatStore } from '../../state/chat-store'
7+
import { useTerminalDimensions } from '../../hooks/use-terminal-dimensions'
8+
9+
import type { ToolRenderConfig } from './types'
10+
import type { SuggestedFollowup } from '../../state/chat-store'
11+
12+
interface FollowupLineProps {
13+
followup: SuggestedFollowup
14+
index: number
15+
isClicked: boolean
16+
onSendFollowup: (prompt: string, index: number) => void
17+
}
18+
19+
const FollowupLine = ({
20+
followup,
21+
index,
22+
isClicked,
23+
onSendFollowup,
24+
}: FollowupLineProps) => {
25+
const theme = useTheme()
26+
const { terminalWidth } = useTerminalDimensions()
27+
const [isHovered, setIsHovered] = useState(false)
28+
29+
const handleClick = useCallback(() => {
30+
if (isClicked) return
31+
onSendFollowup(followup.prompt, index)
32+
}, [followup.prompt, index, onSendFollowup, isClicked])
33+
34+
const handleMouseOver = useCallback(() => setIsHovered(true), [])
35+
const handleMouseOut = useCallback(() => setIsHovered(false), [])
36+
37+
const hasLabel = Boolean(followup.label)
38+
// "→ " = 2 chars (icon + space), " · " separator = 3 chars, "…" = 1 char
39+
const iconWidth = 2
40+
const separatorWidth = hasLabel ? 3 : 0
41+
const ellipsisWidth = 1
42+
const maxWidth = terminalWidth - 6 // Extra margin for safety
43+
44+
// Build the display text with label and prompt
45+
let labelText = followup.label || ''
46+
let promptText = followup.prompt
47+
48+
// Calculate available space
49+
const availableForContent = maxWidth - iconWidth
50+
51+
if (hasLabel) {
52+
// Show: label · prompt (truncated)
53+
const labelWithSeparator = labelText.length + separatorWidth
54+
const totalLength = labelWithSeparator + promptText.length
55+
56+
if (totalLength > availableForContent) {
57+
// Truncate prompt to fit
58+
const availableForPrompt = availableForContent - labelWithSeparator - ellipsisWidth
59+
if (availableForPrompt > 0) {
60+
promptText = promptText.slice(0, availableForPrompt) + '…'
61+
} else {
62+
// Not enough space for prompt, just show label truncated
63+
promptText = ''
64+
if (labelText.length > availableForContent - ellipsisWidth) {
65+
labelText = labelText.slice(0, availableForContent - ellipsisWidth) + '…'
66+
}
67+
}
68+
}
69+
} else {
70+
// No label, just show prompt (truncated)
71+
if (promptText.length > availableForContent) {
72+
promptText = promptText.slice(0, availableForContent - ellipsisWidth) + '…'
73+
}
74+
}
75+
76+
// Determine colors based on state
77+
const iconColor = isClicked
78+
? theme.success
79+
: isHovered
80+
? theme.primary
81+
: theme.muted
82+
const labelColor = isClicked
83+
? theme.muted
84+
: isHovered
85+
? theme.primary
86+
: theme.foreground
87+
const promptColor = isClicked
88+
? theme.muted
89+
: isHovered
90+
? theme.primary
91+
: theme.muted
92+
93+
return (
94+
<box
95+
onMouseDown={handleClick}
96+
onMouseOver={handleMouseOver}
97+
onMouseOut={handleMouseOut}
98+
>
99+
<text selectable={false}>
100+
<span fg={iconColor}>{isClicked ? '✓' : '→'}</span>
101+
<span fg={labelColor} attributes={isHovered ? TextAttributes.UNDERLINE : undefined}>
102+
{' '}{hasLabel ? labelText : promptText}
103+
</span>
104+
{hasLabel && promptText && (
105+
<span fg={promptColor}>
106+
{' · '}{promptText}
107+
</span>
108+
)}
109+
</text>
110+
</box>
111+
)
112+
}
113+
114+
interface SuggestFollowupsItemProps {
115+
toolCallId: string
116+
followups: SuggestedFollowup[]
117+
onSendFollowup: (prompt: string, index: number) => void
118+
}
119+
120+
const SuggestFollowupsItem = ({
121+
toolCallId,
122+
followups,
123+
onSendFollowup,
124+
}: SuggestFollowupsItemProps) => {
125+
const theme = useTheme()
126+
const suggestedFollowups = useChatStore((state) => state.suggestedFollowups)
127+
128+
// Get clicked indices for this specific tool call
129+
const clickedIndices =
130+
suggestedFollowups?.toolCallId === toolCallId
131+
? suggestedFollowups.clickedIndices
132+
: new Set<number>()
133+
134+
return (
135+
<box style={{ flexDirection: 'column' }}>
136+
<text style={{ fg: theme.muted }}>
137+
Next steps:
138+
</text>
139+
{followups.map((followup, index) => (
140+
<FollowupLine
141+
key={`followup-${index}`}
142+
followup={followup}
143+
index={index}
144+
isClicked={clickedIndices.has(index)}
145+
onSendFollowup={onSendFollowup}
146+
/>
147+
))}
148+
</box>
149+
)
150+
}
151+
152+
/**
153+
* UI component for suggest_followups tool.
154+
* Displays clickable cards that send the followup prompt as a user message when clicked.
155+
*/
156+
export const SuggestFollowupsComponent = defineToolComponent({
157+
toolName: 'suggest_followups',
158+
159+
render(toolBlock): ToolRenderConfig {
160+
const { input, toolCallId } = toolBlock
161+
162+
// Extract followups from input
163+
let followups: SuggestedFollowup[] = []
164+
165+
if (Array.isArray(input?.followups)) {
166+
followups = input.followups.filter(
167+
(f: unknown): f is SuggestedFollowup =>
168+
typeof f === 'object' &&
169+
f !== null &&
170+
typeof (f as SuggestedFollowup).prompt === 'string',
171+
)
172+
}
173+
174+
if (followups.length === 0) {
175+
return { content: null }
176+
}
177+
178+
// Store the followups in state for tracking clicks
179+
// This is done via a ref to avoid re-renders during the render phase
180+
const store = useChatStore.getState()
181+
if (
182+
!store.suggestedFollowups ||
183+
store.suggestedFollowups.toolCallId !== toolCallId
184+
) {
185+
// Schedule the state update for after render
186+
setTimeout(() => {
187+
useChatStore.getState().setSuggestedFollowups({
188+
toolCallId,
189+
followups,
190+
clickedIndices: new Set(),
191+
})
192+
}, 0)
193+
}
194+
195+
// The actual click handling is done in chat.tsx via the global handler
196+
// Here we just pass a placeholder that will be replaced
197+
const handleSendFollowup = (prompt: string, index: number) => {
198+
// This gets called from the FollowupCard component
199+
// The actual logic is handled via the global followup handler
200+
const event = new CustomEvent('codebuff:send-followup', {
201+
detail: { prompt, index },
202+
})
203+
globalThis.dispatchEvent(event)
204+
}
205+
206+
return {
207+
content: (
208+
<SuggestFollowupsItem
209+
toolCallId={toolCallId}
210+
followups={followups}
211+
onSendFollowup={handleSendFollowup}
212+
/>
213+
),
214+
}
215+
},
216+
})

0 commit comments

Comments
 (0)