Skip to content

Commit d258c5f

Browse files
jimgqyuclaude
andcommitted
feat: unify tool output display with bold names, gray results, and visual-line folding
- All tools now render with bold tool names and gray/muted results, matching the Bash tool style. Removed the legacy "Args:" block from trail lines. - Tool result content longer than 4 lines auto-folds with "click to expand". - Thought preview uses visual-line counting (terminal-column-based) so long single lines also trigger folding during streaming. - Active tools without descriptions no longer duplicate the tool-call line in their detail (the header already identifies the tool). - Removed chevron arrows (▾/▸) from section headers, simplified label colors. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e8527f8 commit d258c5f

2 files changed

Lines changed: 169 additions & 58 deletions

File tree

packages/cli/src/components/thinking.tsx

Lines changed: 160 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -205,12 +205,21 @@ function TruncatedResult({
205205
t
206206
}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) {
207207
const [expanded, setExpanded] = useState(false)
208-
const text = typeof content === 'string' ? content : String(content ?? '')
208+
const text = typeof content === 'string' ? content : ''
209+
const isReactNode = !text && content
209210
const lines = useMemo(() => text.split('\n'), [text])
210-
const truncated = lines.length > RESULT_PREVIEW_LINES
211+
const truncated = !isReactNode && lines.length > RESULT_PREVIEW_LINES
211212
const visible = expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES)
212213
const hidden = lines.length - RESULT_PREVIEW_LINES
213214

215+
if (isReactNode) {
216+
return (
217+
<TreeRow branch={branch} rails={rails} t={t}>
218+
{content as ReactNode}
219+
</TreeRow>
220+
)
221+
}
222+
214223
return (
215224
<Box flexDirection="column">
216225
{visible.map((line, i) => (
@@ -227,7 +236,7 @@ function TruncatedResult({
227236
{truncated && !expanded ? (
228237
<TreeRow branch={branch} rails={rails} t={t}>
229238
<Box onClick={() => setExpanded(true)}>
230-
<Text color={t.color.accent}>... +{hidden} lines (click to expand)</Text>
239+
<Text color={t.color.muted} dim>... +{hidden} lines (click to expand)</Text>
231240
</Box>
232241
</TreeRow>
233242
) : null}
@@ -297,12 +306,11 @@ function Chevron({
297306
title: string
298307
tone?: 'dim' | 'error' | 'warn'
299308
}) {
300-
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.muted
309+
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.text
301310

302311
return (
303312
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
304-
<Text color={color} dim={tone === 'dim'}>
305-
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
313+
<Text color={color}>
306314
{title}
307315
{typeof count === 'number' ? ` (${count})` : ''}
308316
{suffix ? (
@@ -792,7 +800,7 @@ export const ToolTrail = memo(function ToolTrail({
792800
// sections (regression caught after #14968).
793801
const [openThinking, setOpenThinking] = useState(visible.thinking === 'expanded')
794802
const thinkingUserToggledRef = useRef(false)
795-
const [openTools, setOpenTools] = useState(visible.tools === 'expanded')
803+
const [openTools, setOpenTools] = useState(true)
796804
const toolsUserToggledRef = useRef(false)
797805
const [openSubagents, setOpenSubagents] = useState(visible.subagents === 'expanded')
798806
const [deepSubagents, setDeepSubagents] = useState(visible.subagents === 'expanded')
@@ -864,7 +872,7 @@ export const ToolTrail = memo(function ToolTrail({
864872

865873
if (parsed.detail) {
866874
pushDetail({
867-
color: parsed.mark === '✗' ? t.color.error : t.color.muted,
875+
color: parsed.mark === '✗' ? t.color.error : t.color.text,
868876
content: parsed.detail,
869877
dimColor: parsed.mark !== '✗',
870878
key: `tr-${i}-d`
@@ -911,21 +919,32 @@ export const ToolTrail = memo(function ToolTrail({
911919
for (const tool of tools) {
912920
const args = parseToolArgs(tool.verboseArgs ?? '')
913921
const label = args?.description ?? formatToolCall(tool.name, tool.verboseArgs || tool.context || '')
922+
const hasDescription = Boolean(args?.description)
914923
const details: DetailRow[] = []
915924
if (args?.command) {
916-
details.push({
917-
color: t.color.muted,
918-
content: formatToolCall(tool.name, args.command),
919-
dimColor: true,
920-
key: `${tool.id}-cmd`
921-
})
925+
if (hasDescription) {
926+
// Header shows the description — detail shows the tool call.
927+
const toolLine = formatToolCall(tool.name, args.command)
928+
details.push({
929+
color: t.color.muted,
930+
content: toolLine,
931+
dimColor: true,
932+
key: `${tool.id}-cmd`
933+
})
934+
}
935+
// Without a description the header already shows the tool call —
936+
// no need to duplicate it in the detail.
922937
} else if (tool.verboseArgs) {
923-
details.push({
924-
color: t.color.muted,
925-
content: `Args:\n${boundedLiveRenderText(tool.verboseArgs)}`,
926-
dimColor: true,
927-
key: `${tool.id}-args`
928-
})
938+
if (hasDescription) {
939+
const toolLine = formatToolCall(tool.name, tool.verboseArgs || tool.context || '')
940+
details.push({
941+
color: t.color.muted,
942+
content: `${toolLine}\n${boundedLiveRenderText(tool.verboseArgs)}`,
943+
dimColor: true,
944+
key: `${tool.id}-args`
945+
})
946+
}
947+
// Without a description, header = tool call, skip the detail.
929948
}
930949

931950
groups.push({
@@ -955,22 +974,42 @@ export const ToolTrail = memo(function ToolTrail({
955974
const hasMeta = meta.length > 0
956975
const hasThinking = !!cot || reasoningActive || reasoningStreaming
957976
const thinkingLive = reasoningActive || reasoningStreaming
977+
const terminalCols = process.stdout.columns || 80
958978
const thoughtPreviewLines = useMemo(() => {
959-
if (thinkingLive) return []
960979
const raw = cleanThinkingText(reasoning)
961-
return raw.split('\n').filter(Boolean).slice(0, THOUGHT_PREVIEW_LINES)
962-
}, [thinkingLive, reasoning])
980+
const sourceLines = raw.split('\n').filter(Boolean)
981+
// Collect source lines until they fill THOUGHT_PREVIEW_LINES visual lines
982+
const result: string[] = []
983+
let visualLines = 0
984+
for (const line of sourceLines) {
985+
result.push(line)
986+
visualLines += Math.max(1, Math.ceil(line.length / terminalCols))
987+
if (visualLines >= THOUGHT_PREVIEW_LINES) break
988+
}
989+
return result
990+
}, [reasoning, terminalCols])
991+
const totalThoughtLines = useMemo(() => {
992+
const raw = cleanThinkingText(reasoning)
993+
const sourceLines = raw.split('\n').filter(Boolean)
994+
return sourceLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / terminalCols)), 0)
995+
}, [reasoning, terminalCols])
963996

964997
// Auto-expand Thinking during streaming, revert to user preference when done.
965998
// User clicks take precedence for the remainder of the turn.
999+
// Collapse to preview once reasoning exceeds THOUGHT_PREVIEW_LINES to avoid
1000+
// flooding the screen during long streaming output.
9661001
useEffect(() => {
9671002
if (thinkingLive) {
9681003
thinkingUserToggledRef.current = false
969-
setOpenThinking(true)
1004+
if (totalThoughtLines > THOUGHT_PREVIEW_LINES) {
1005+
setOpenThinking(false)
1006+
} else {
1007+
setOpenThinking(true)
1008+
}
9701009
} else if (!thinkingUserToggledRef.current) {
9711010
setOpenThinking(visible.thinking === 'expanded')
9721011
}
973-
}, [thinkingLive, visible.thinking])
1012+
}, [thinkingLive, visible.thinking, totalThoughtLines])
9741013

9751014
// Auto-expand Tool calls while tools are running, revert to user preference when done.
9761015
// Same logic as Thinking: user clicks take precedence for the remainder of the turn.
@@ -981,7 +1020,7 @@ export const ToolTrail = memo(function ToolTrail({
9811020
toolsUserToggledRef.current = false
9821021
setOpenTools(true)
9831022
} else if (!toolsUserToggledRef.current && hasTools) {
984-
setOpenTools(visible.tools === 'expanded')
1023+
setOpenTools(true)
9851024
}
9861025
}, [toolsActive, hasTools, visible.tools])
9871026

@@ -1086,7 +1125,7 @@ export const ToolTrail = memo(function ToolTrail({
10861125
}[] = []
10871126

10881127
if (hasThinking && visible.thinking !== 'hidden') {
1089-
const showPreview = !openThinking && !thinkingLive && thoughtPreviewLines.length > 0
1128+
const showPreview = !openThinking && thoughtPreviewLines.length > 0
10901129
panels.push({
10911130
header: (
10921131
<Box
@@ -1101,14 +1140,13 @@ export const ToolTrail = memo(function ToolTrail({
11011140
}}
11021141
>
11031142
<Box>
1104-
<Text color={t.color.muted} dim={!thinkingLive}>
1105-
<Text color={t.color.accent}>{openThinking ? '▾ ' : '▸ '}</Text>
1143+
<Text color={t.color.text}>
11061144
{thinkingLive ? (
11071145
<Text bold color={t.color.text}>
11081146
Thinking
11091147
</Text>
11101148
) : (
1111-
<Text color={t.color.muted} dim>
1149+
<Text bold color={t.color.text}>
11121150
Thought
11131151
</Text>
11141152
)}
@@ -1121,26 +1159,44 @@ export const ToolTrail = memo(function ToolTrail({
11211159
</Text>
11221160
</Box>
11231161
{showPreview
1124-
? thoughtPreviewLines.map((line, i) => (
1125-
<Text color={t.color.muted} dim key={i} wrap="truncate-end">
1126-
{line || ' '}
1127-
</Text>
1128-
))
1162+
? (
1163+
<>
1164+
{thoughtPreviewLines.map((line, i) => (
1165+
<Text color={t.color.muted} key={i} wrap="wrap-trim">
1166+
{line || ' '}
1167+
</Text>
1168+
))}
1169+
{totalThoughtLines > THOUGHT_PREVIEW_LINES ? (
1170+
<Text color={t.color.muted} dim>
1171+
... +{totalThoughtLines - THOUGHT_PREVIEW_LINES} lines (click to expand)
1172+
</Text>
1173+
) : null}
1174+
</>
1175+
)
11291176
: null}
11301177
</Box>
11311178
),
11321179
key: 'thinking',
11331180
open: openThinking,
11341181
render: rails => (
1135-
<Thinking
1136-
active={reasoningActive}
1137-
branch="last"
1138-
mode="full"
1139-
rails={rails}
1140-
reasoning={busy ? reasoning : cot}
1141-
streaming={busy && reasoningStreaming}
1142-
t={t}
1143-
/>
1182+
<Box flexDirection="column">
1183+
<Thinking
1184+
active={reasoningActive}
1185+
branch={totalThoughtLines > THOUGHT_PREVIEW_LINES ? 'mid' : 'last'}
1186+
mode="full"
1187+
rails={rails}
1188+
reasoning={busy ? reasoning : cot}
1189+
streaming={busy && reasoningStreaming}
1190+
t={t}
1191+
/>
1192+
{!thinkingLive && totalThoughtLines > THOUGHT_PREVIEW_LINES ? (
1193+
<TreeRow branch="last" rails={rails} t={t}>
1194+
<Box onClick={() => setOpenThinking(false)}>
1195+
<Text color={t.color.muted} dim>... collapse</Text>
1196+
</Box>
1197+
</TreeRow>
1198+
) : null}
1199+
</Box>
11441200
)
11451201
})
11461202
}
@@ -1150,7 +1206,8 @@ export const ToolTrail = memo(function ToolTrail({
11501206
const { duration, label: toolHeaderLabel } = splitToolDuration(group.label)
11511207
const tone: 'dim' | 'error' | 'warn' = group.color === t.color.error ? 'error' : 'dim'
11521208
const isLastGroup = groupIndex === groups.length - 1
1153-
const suffix = [duration, isLastGroup ? toolTokensLabel : undefined].filter(Boolean).join(' ') || undefined
1209+
const suffix = [duration, isLastGroup ? toolTokensLabel : undefined]
1210+
.filter(Boolean).join(' ') || undefined
11541211

11551212
panels.push({
11561213
header: (
@@ -1181,6 +1238,64 @@ export const ToolTrail = memo(function ToolTrail({
11811238
{group.details.map((detail, detailIndex) => {
11821239
const detailBranch: TreeBranch =
11831240
detailIndex === group.details.length - 1 && !hasInlineSubagents ? 'last' : 'mid'
1241+
const text = typeof detail.content === 'string' ? detail.content : ''
1242+
const nl = text.indexOf('\n')
1243+
// Bold the tool name in "Bash(command)" first line
1244+
const toolCallMatch = nl > 0 ? text.slice(0, nl).match(/^(\w+)\((.+)\)$/) : null
1245+
1246+
if (toolCallMatch) {
1247+
const toolName = toolCallMatch[1]!
1248+
const toolArg = toolCallMatch[2]!
1249+
const rest = text.slice(nl + 1)
1250+
const restNl = rest.indexOf('\n')
1251+
const resultLabel = restNl > 0 && rest.startsWith('Result:') ? 'Result:' : ''
1252+
const resultBody = resultLabel ? rest.slice(restNl + 1) : rest
1253+
return (
1254+
<Box flexDirection="column" key={detail.key}>
1255+
<TreeTextRow
1256+
branch={resultLabel || resultBody !== rest ? 'mid' : detailBranch}
1257+
color={detail.color}
1258+
content={
1259+
<Text>
1260+
<Text bold color={detail.color}>{toolName}</Text>
1261+
<Text color={detail.color}>({toolArg})</Text>
1262+
</Text>
1263+
}
1264+
rails={rails}
1265+
t={t}
1266+
/>
1267+
{resultLabel ? (
1268+
<>
1269+
<TreeTextRow
1270+
branch="mid"
1271+
color={detail.color}
1272+
content="Result:"
1273+
rails={rails}
1274+
t={t}
1275+
/>
1276+
<TruncatedResult
1277+
{...detail}
1278+
branch={detailBranch}
1279+
color={t.color.muted}
1280+
content={resultBody}
1281+
dimColor
1282+
rails={rails}
1283+
t={t}
1284+
/>
1285+
</>
1286+
) : (
1287+
<TruncatedResult
1288+
{...detail}
1289+
branch={detailBranch}
1290+
content={rest}
1291+
rails={rails}
1292+
t={t}
1293+
/>
1294+
)}
1295+
</Box>
1296+
)
1297+
}
1298+
11841299
return (
11851300
<TruncatedResult
11861301
{...detail}

packages/cli/src/lib/text.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -299,20 +299,16 @@ export const buildVerboseToolTrailLine = (
299299
const effectiveHeader = headerLabel ?? formatToolCall(name, context)
300300
const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : ''
301301

302-
// When we have a parsed description+command, show:
303-
// header: description
304-
// detail: Bash(command)\nResult:\n...
305-
// Otherwise keep the legacy Args:/Result: format.
302+
// Always include the tool-call line so the TUI renders a bold tool name.
303+
// Skip the legacy "Args:" block — the result (or error) is the signal.
304+
// When there is no headerLabel (description), the header already shows the
305+
// tool call — omit it from the detail to avoid showing it twice.
306306
let detail: string
307-
if (args?.command && args?.description) {
308-
const toolLine = formatToolCall(name, args.command)
309-
const resultBlock = verboseToolBlock(error ? 'Error' : 'Result', resultText)
310-
detail = [toolLine, resultBlock].filter(Boolean).join('\n')
311-
} else {
312-
detail = [verboseToolBlock('Args', argsText), verboseToolBlock(error ? 'Error' : 'Result', resultText)]
313-
.filter(Boolean)
314-
.join('\n')
315-
}
307+
const toolLine = formatToolCall(name, args?.command ?? context)
308+
const resultBlock = verboseToolBlock(error ? 'Error' : 'Result', resultText)
309+
detail = headerLabel
310+
? [toolLine, resultBlock].filter(Boolean).join('\n')
311+
: resultBlock
316312

317313
return `${effectiveHeader}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}`
318314
}

0 commit comments

Comments
 (0)