Skip to content

Commit 660736b

Browse files
committed
feat(cli): add customizable tool rendering system
- Create tool-renderer component for custom tool display logic - Add special rendering for list_directory with path and file summaries - Store raw tool output alongside formatted output - Add title accessory support to ToolItem component - Enable custom collapsed previews and content rendering - Add tool name display overrides for better UX
1 parent 79d5abc commit 660736b

File tree

6 files changed

+204
-6
lines changed

6 files changed

+204
-6
lines changed

cli/src/chat.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type ContentBlock =
6161
toolName: ToolName
6262
input: any
6363
output?: string
64+
outputRaw?: unknown
6465
agentId?: string
6566
}
6667
| {

cli/src/components/message-block.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { pluralize } from '@codebuff/common/util/string'
55

66
import { BranchItem } from './branch-item'
77
import { ToolItem } from './tool-item'
8+
import { getToolRenderConfig } from './tool-renderer'
89
import { getToolDisplayInfo } from '../utils/codebuff-client'
910
import {
1011
renderMarkdown,
@@ -92,6 +93,13 @@ export const MessageBlock = ({
9293
const displayInfo = getToolDisplayInfo(toolBlock.toolName)
9394
const isCollapsed = collapsedAgents.has(toolBlock.toolCallId)
9495
const isStreaming = streamingAgents.has(toolBlock.toolCallId)
96+
const indentationOffset = indentLevel * 2
97+
98+
const { titleAccessory, content: customContent, collapsedPreview } =
99+
getToolRenderConfig(toolBlock, theme, {
100+
availableWidth,
101+
indentationOffset,
102+
})
95103

96104
const inputContent = `\`\`\`json\n${JSON.stringify(toolBlock.input, null, 2)}\n\`\`\``
97105
const codeBlockLang =
@@ -137,11 +145,15 @@ export const MessageBlock = ({
137145
}
138146

139147
const agentMarkdownOptions = getAgentMarkdownOptions(indentLevel)
140-
const displayContent = hasMarkdown(fullContent)
141-
? renderMarkdown(fullContent, agentMarkdownOptions)
142-
: fullContent
148+
const displayContent =
149+
customContent ??
150+
(hasMarkdown(fullContent)
151+
? renderMarkdown(fullContent, agentMarkdownOptions)
152+
: fullContent)
143153

144-
const indentationOffset = indentLevel * 2
154+
if (!isStreaming && isCollapsed && collapsedPreview) {
155+
finishedPreview = collapsedPreview
156+
}
145157

146158
return (
147159
<box
@@ -151,6 +163,7 @@ export const MessageBlock = ({
151163
>
152164
<ToolItem
153165
name={displayInfo.name}
166+
titleAccessory={titleAccessory}
154167
content={displayContent}
155168
isCollapsed={isCollapsed}
156169
isStreaming={isStreaming}

cli/src/components/tool-item.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ChatTheme } from '../utils/theme-system'
55

66
interface ToolItemProps {
77
name: string
8+
titleAccessory?: ReactNode
89
content: ReactNode
910
isCollapsed: boolean
1011
isStreaming: boolean
@@ -57,6 +58,7 @@ const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => {
5758

5859
export const ToolItem = ({
5960
name,
61+
titleAccessory,
6062
content,
6163
isCollapsed,
6264
isStreaming,
@@ -68,6 +70,10 @@ export const ToolItem = ({
6870
const toggleColor = theme.statusSecondary
6971
const toggleIcon = isCollapsed ? '▸' : '▾'
7072
const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount
73+
const hasTitleAccessory =
74+
titleAccessory !== undefined &&
75+
titleAccessory !== null &&
76+
!(typeof titleAccessory === 'string' && titleAccessory.length === 0)
7177

7278
return (
7379
<box style={{ flexDirection: 'column', gap: 0 }}>
@@ -87,6 +93,12 @@ export const ToolItem = ({
8793
<span fg={toggleColor} attributes={TextAttributes.BOLD}>
8894
{name}
8995
</span>
96+
{hasTitleAccessory ? (
97+
<>
98+
{' '}
99+
{titleAccessory}
100+
</>
101+
) : null}
90102
</text>
91103
</box>
92104
{isCollapsed ? (
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import React from 'react'
3+
import stringWidth from 'string-width'
4+
5+
import type { ContentBlock } from '../chat'
6+
import type { ChatTheme } from '../utils/theme-system'
7+
8+
type ToolBlock = Extract<ContentBlock, { type: 'tool' }>
9+
10+
export type ToolRenderConfig = {
11+
titleAccessory?: React.ReactNode
12+
content?: React.ReactNode
13+
collapsedPreview?: string
14+
}
15+
16+
export type ToolRenderOptions = {
17+
availableWidth: number
18+
indentationOffset: number
19+
}
20+
21+
const isRecord = (value: unknown): value is Record<string, unknown> => {
22+
return typeof value === 'object' && value !== null
23+
}
24+
25+
const extractPath = (toolBlock: ToolBlock, resultValue: unknown): string | null => {
26+
if (isRecord(toolBlock.input) && typeof toolBlock.input.path === 'string') {
27+
const trimmed = toolBlock.input.path.trim()
28+
if (trimmed.length > 0) {
29+
return trimmed
30+
}
31+
}
32+
33+
if (isRecord(resultValue) && typeof resultValue.path === 'string') {
34+
const trimmed = resultValue.path.trim()
35+
if (trimmed.length > 0) {
36+
return trimmed
37+
}
38+
}
39+
40+
return null
41+
}
42+
43+
const summarizeFiles = (
44+
entries: unknown,
45+
maxItems: number,
46+
options: ToolRenderOptions,
47+
): string | null => {
48+
const maxWidth = Math.max(
49+
20,
50+
options.availableWidth - options.indentationOffset - 6,
51+
)
52+
53+
if (!Array.isArray(entries) || entries.length === 0) {
54+
return null
55+
}
56+
57+
const validNames = entries
58+
.filter((entry): entry is string => typeof entry === 'string')
59+
.map((entry) => entry.trim())
60+
.filter((entry) => entry.length > 0)
61+
62+
if (validNames.length === 0) {
63+
return null
64+
}
65+
66+
const summaryNames: string[] = []
67+
let widthUsed = 0
68+
69+
for (let index = 0; index < validNames.length; index += 1) {
70+
if (summaryNames.length >= maxItems) {
71+
break
72+
}
73+
74+
const name = validNames[index]
75+
const prefix = summaryNames.length === 0 ? '' : ', '
76+
const candidate = `${prefix}${name}`
77+
const candidateWidth = stringWidth(candidate)
78+
const wouldExceedWidth = widthUsed + candidateWidth > maxWidth
79+
80+
if (summaryNames.length > 0 && wouldExceedWidth) {
81+
break
82+
}
83+
84+
summaryNames.push(name)
85+
widthUsed += candidateWidth
86+
87+
if (summaryNames.length === 1 && wouldExceedWidth) {
88+
break
89+
}
90+
}
91+
92+
if (summaryNames.length === 0) {
93+
summaryNames.push(validNames[0])
94+
}
95+
96+
const hasMore = summaryNames.length < validNames.length
97+
const summary = summaryNames.join(', ')
98+
return hasMore ? `${summary}, ...` : summary
99+
}
100+
101+
const getListDirectoryRender = (
102+
toolBlock: ToolBlock,
103+
theme: ChatTheme,
104+
options: ToolRenderOptions,
105+
): ToolRenderConfig => {
106+
const MAX_ITEMS = 3
107+
const resultValue = Array.isArray(toolBlock.outputRaw)
108+
? (toolBlock.outputRaw[0] as any)?.value
109+
: undefined
110+
111+
if (!isRecord(resultValue)) {
112+
return {}
113+
}
114+
115+
const filesLine = summarizeFiles(resultValue.files, MAX_ITEMS, options)
116+
const fallbackLine = filesLine
117+
? null
118+
: summarizeFiles(resultValue.directories, MAX_ITEMS, options)
119+
const path = extractPath(toolBlock, resultValue)
120+
121+
const summaryLine = filesLine ?? fallbackLine
122+
123+
if (!summaryLine && !path) {
124+
return {}
125+
}
126+
127+
const content =
128+
summaryLine !== null ? (
129+
<text
130+
fg={theme.agentResponseCount}
131+
attributes={TextAttributes.ITALIC}
132+
style={{ wrapMode: 'word' }}
133+
>
134+
{summaryLine}
135+
</text>
136+
) : null
137+
138+
const collapsedPreview = summaryLine ?? undefined
139+
140+
const titleAccessory = path ? (
141+
<span fg={theme.agentText} attributes={TextAttributes.BOLD}>
142+
{path}
143+
</span>
144+
) : undefined
145+
146+
return {
147+
titleAccessory,
148+
content,
149+
collapsedPreview,
150+
}
151+
}
152+
153+
export const getToolRenderConfig = (
154+
toolBlock: ToolBlock,
155+
theme: ChatTheme,
156+
options: ToolRenderOptions,
157+
): ToolRenderConfig => {
158+
switch (toolBlock.toolName) {
159+
case 'list_directory':
160+
return getListDirectoryRender(toolBlock, theme, options)
161+
default:
162+
return {}
163+
}
164+
}

cli/src/hooks/use-send-message.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,7 @@ export const useSendMessage = ({
11051105
toolName,
11061106
input,
11071107
agentId,
1108+
outputRaw: undefined,
11081109
}
11091110

11101111
return {
@@ -1134,6 +1135,7 @@ export const useSendMessage = ({
11341135
toolName,
11351136
input,
11361137
agentId,
1138+
outputRaw: undefined,
11371139
}
11381140

11391141
return {
@@ -1247,6 +1249,8 @@ export const useSendMessage = ({
12471249
const updateToolBlock = (
12481250
blocks: ContentBlock[],
12491251
): ContentBlock[] => {
1252+
const rawOutput = event.output
1253+
12501254
return blocks.map((block) => {
12511255
if (
12521256
block.type === 'tool' &&
@@ -1265,7 +1269,7 @@ export const useSendMessage = ({
12651269
} else {
12661270
output = formatToolOutput(event.output)
12671271
}
1268-
return { ...block, output }
1272+
return { ...block, output, outputRaw: rawOutput }
12691273
} else if (block.type === 'agent' && block.blocks) {
12701274
return { ...block, blocks: updateToolBlock(block.blocks) }
12711275
}

cli/src/utils/codebuff-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@ export function getToolDisplayInfo(toolName: string): {
4141
name: string
4242
type: string
4343
} {
44+
const TOOL_NAME_OVERRIDES: Record<string, string> = {
45+
list_directory: 'List Directories',
46+
}
47+
4448
const capitalizeWords = (str: string) => {
4549
return str.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
4650
}
4751

4852
return {
49-
name: capitalizeWords(toolName),
53+
name: TOOL_NAME_OVERRIDES[toolName] ?? capitalizeWords(toolName),
5054
type: 'tool',
5155
}
5256
}

0 commit comments

Comments
 (0)