Skip to content

Commit a77a147

Browse files
committed
Initial followup tool impl
1 parent 1892a93 commit a77a147

File tree

12 files changed

+418
-0
lines changed

12 files changed

+418
-0
lines changed

.agents/base2/base2.ts

Lines changed: 5 additions & 0 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',
@@ -308,6 +309,8 @@ ${buildArray(
308309
!hasNoValidation &&
309310
`- 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!`,
310311
`- 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").`,
311314
).join('\n')}`
312315
}
313316

@@ -332,6 +335,8 @@ function buildImplementationStepPrompt({
332335
(isDefault || isMax) &&
333336
'Spawn code-reviewer-opus to review the changes after you have implemented the changes and in parallel with typechecking or testing.',
334337
`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.`,
335340
).join('\n')
336341
}
337342

.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: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import React, { useCallback } from 'react'
2+
3+
import { Button } from '../button'
4+
import { defineToolComponent } from './types'
5+
import { useTheme } from '../../hooks/use-theme'
6+
import { useChatStore } from '../../state/chat-store'
7+
8+
import type { ToolRenderConfig } from './types'
9+
import type { SuggestedFollowup } from '../../state/chat-store'
10+
11+
interface FollowupCardProps {
12+
followup: SuggestedFollowup
13+
index: number
14+
isClicked: boolean
15+
onSendFollowup: (prompt: string, index: number) => void
16+
}
17+
18+
const FollowupCard = ({
19+
followup,
20+
index,
21+
isClicked,
22+
onSendFollowup,
23+
}: FollowupCardProps) => {
24+
const theme = useTheme()
25+
26+
const handleClick = useCallback(() => {
27+
onSendFollowup(followup.prompt, index)
28+
}, [followup.prompt, index, onSendFollowup])
29+
30+
// Use label if provided, otherwise truncate the prompt
31+
const displayLabel = followup.label || truncateText(followup.prompt, 40)
32+
33+
return (
34+
<Button
35+
onClick={handleClick}
36+
style={{
37+
paddingLeft: 2,
38+
paddingRight: 2,
39+
paddingTop: 0,
40+
paddingBottom: 0,
41+
backgroundColor: isClicked ? theme.surface : theme.surfaceHover,
42+
borderColor: isClicked ? theme.success : theme.border,
43+
}}
44+
>
45+
<text
46+
style={{
47+
fg: isClicked ? theme.muted : theme.foreground,
48+
}}
49+
>
50+
{isClicked && <span fg={theme.success}></span>}
51+
<span>{displayLabel}</span>
52+
</text>
53+
</Button>
54+
)
55+
}
56+
57+
function truncateText(text: string, maxLength: number): string {
58+
if (text.length <= maxLength) return text
59+
return text.slice(0, maxLength - 1) + '…'
60+
}
61+
62+
interface SuggestFollowupsItemProps {
63+
toolCallId: string
64+
followups: SuggestedFollowup[]
65+
onSendFollowup: (prompt: string, index: number) => void
66+
}
67+
68+
const SuggestFollowupsItem = ({
69+
toolCallId,
70+
followups,
71+
onSendFollowup,
72+
}: SuggestFollowupsItemProps) => {
73+
const theme = useTheme()
74+
const suggestedFollowups = useChatStore((state) => state.suggestedFollowups)
75+
76+
// Get clicked indices for this specific tool call
77+
const clickedIndices =
78+
suggestedFollowups?.toolCallId === toolCallId
79+
? suggestedFollowups.clickedIndices
80+
: new Set<number>()
81+
82+
return (
83+
<box style={{ flexDirection: 'column', gap: 1, width: '100%' }}>
84+
<text style={{ fg: theme.muted }}>Suggested next steps:</text>
85+
<box
86+
style={{
87+
flexDirection: 'row',
88+
gap: 1,
89+
flexWrap: 'wrap',
90+
width: '100%',
91+
}}
92+
>
93+
{followups.map((followup, index) => (
94+
<FollowupCard
95+
key={`followup-${index}`}
96+
followup={followup}
97+
index={index}
98+
isClicked={clickedIndices.has(index)}
99+
onSendFollowup={onSendFollowup}
100+
/>
101+
))}
102+
</box>
103+
</box>
104+
)
105+
}
106+
107+
/**
108+
* UI component for suggest_followups tool.
109+
* Displays clickable cards that send the followup prompt as a user message when clicked.
110+
*/
111+
export const SuggestFollowupsComponent = defineToolComponent({
112+
toolName: 'suggest_followups',
113+
114+
render(toolBlock): ToolRenderConfig {
115+
const { input, toolCallId } = toolBlock
116+
117+
// Extract followups from input
118+
let followups: SuggestedFollowup[] = []
119+
120+
if (Array.isArray(input?.followups)) {
121+
followups = input.followups.filter(
122+
(f: unknown): f is SuggestedFollowup =>
123+
typeof f === 'object' &&
124+
f !== null &&
125+
typeof (f as SuggestedFollowup).prompt === 'string',
126+
)
127+
}
128+
129+
if (followups.length === 0) {
130+
return { content: null }
131+
}
132+
133+
// Store the followups in state for tracking clicks
134+
// This is done via a ref to avoid re-renders during the render phase
135+
const store = useChatStore.getState()
136+
if (
137+
!store.suggestedFollowups ||
138+
store.suggestedFollowups.toolCallId !== toolCallId
139+
) {
140+
// Schedule the state update for after render
141+
setTimeout(() => {
142+
useChatStore.getState().setSuggestedFollowups({
143+
toolCallId,
144+
followups,
145+
clickedIndices: new Set(),
146+
})
147+
}, 0)
148+
}
149+
150+
// The actual click handling is done in chat.tsx via the global handler
151+
// Here we just pass a placeholder that will be replaced
152+
const handleSendFollowup = (prompt: string, index: number) => {
153+
// This gets called from the FollowupCard component
154+
// The actual logic is handled via the global followup handler
155+
const event = new CustomEvent('codebuff:send-followup', {
156+
detail: { prompt, index },
157+
})
158+
globalThis.dispatchEvent(event)
159+
}
160+
161+
return {
162+
content: (
163+
<SuggestFollowupsItem
164+
toolCallId={toolCallId}
165+
followups={followups}
166+
onSendFollowup={handleSendFollowup}
167+
/>
168+
),
169+
}
170+
},
171+
})

cli/src/state/chat-store.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ export type PendingBashMessage = {
7474
addedToHistory?: boolean
7575
}
7676

77+
export type SuggestedFollowup = {
78+
prompt: string
79+
label?: string
80+
}
81+
82+
export type SuggestedFollowupsState = {
83+
/** The tool call ID that created these followups */
84+
toolCallId: string
85+
/** The list of followup suggestions */
86+
followups: SuggestedFollowup[]
87+
/** Set of indices that have been clicked */
88+
clickedIndices: Set<number>
89+
}
90+
7791
export type ChatStoreState = {
7892
messages: ChatMessage[]
7993
streamingAgents: Set<string>
@@ -98,6 +112,7 @@ export type ChatStoreState = {
98112
askUserState: AskUserState
99113
pendingImages: PendingImage[]
100114
pendingBashMessages: PendingBashMessage[]
115+
suggestedFollowups: SuggestedFollowupsState | null
101116
}
102117

103118
type ChatStoreActions = {
@@ -143,6 +158,8 @@ type ChatStoreActions = {
143158
) => void
144159
removePendingBashMessage: (id: string) => void
145160
clearPendingBashMessages: () => void
161+
setSuggestedFollowups: (state: SuggestedFollowupsState | null) => void
162+
markFollowupClicked: (index: number) => void
146163
reset: () => void
147164
}
148165

@@ -172,6 +189,7 @@ const initialState: ChatStoreState = {
172189
askUserState: null,
173190
pendingImages: [],
174191
pendingBashMessages: [],
192+
suggestedFollowups: null,
175193
}
176194

177195
export const useChatStore = create<ChatStore>()(
@@ -382,6 +400,18 @@ export const useChatStore = create<ChatStore>()(
382400
state.pendingBashMessages = []
383401
}),
384402

403+
setSuggestedFollowups: (suggestedFollowups) =>
404+
set((state) => {
405+
state.suggestedFollowups = suggestedFollowups
406+
}),
407+
408+
markFollowupClicked: (index) =>
409+
set((state) => {
410+
if (state.suggestedFollowups) {
411+
state.suggestedFollowups.clickedIndices.add(index)
412+
}
413+
}),
414+
385415
reset: () =>
386416
set((state) => {
387417
state.messages = initialState.messages.slice()
@@ -409,6 +439,7 @@ export const useChatStore = create<ChatStore>()(
409439
state.askUserState = initialState.askUserState
410440
state.pendingImages = []
411441
state.pendingBashMessages = []
442+
state.suggestedFollowups = null
412443
}),
413444
})),
414445
)

0 commit comments

Comments
 (0)