feat(tui): tab expand tool calls cleanly (#8136)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Alex Hancock
2026-03-26 21:04:57 -04:00
committed by GitHub
parent 907c661f3e
commit 0a1f773e7d
4 changed files with 61 additions and 40 deletions
Generated
+2 -2
View File
@@ -11300,9 +11300,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.1" version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
-1
View File
@@ -9,6 +9,5 @@ unsound = "none"
# Ignore proc-macro-error unmaintained warning # Ignore proc-macro-error unmaintained warning
# See: https://github.com/block/goose/issues/7008 # See: https://github.com/block/goose/issues/7008
ignore = [ ignore = [
"RUSTSEC-2024-0370", # proc-macro-error is unmaintained
"RUSTSEC-2023-0071", # rsa: Marvin Attack timing sidechannel (no safe upgrade available, via jsonwebtoken) "RUSTSEC-2023-0071", # rsa: Marvin Attack timing sidechannel (no safe upgrade available, via jsonwebtoken)
] ]
+39 -14
View File
@@ -171,7 +171,6 @@ export function buildToolCallCardLines(
const bodyRows: Array<{ text: string; color?: string; italic?: boolean }> = []; const bodyRows: Array<{ text: string; color?: string; italic?: boolean }> = [];
const runningText = info.status === "in_progress" ? " running…" : ""; const runningText = info.status === "in_progress" ? " running…" : "";
const tabHint = hasTruncated && !expanded ? "tab ↔" : "";
bodyRows.push({ text: "__HEADER__" }); bodyRows.push({ text: "__HEADER__" });
if (hasLocations) { if (hasLocations) {
@@ -220,7 +219,6 @@ export function buildToolCallCardLines(
<Text color={TEXT_SECONDARY} bold>{info.title}</Text> <Text color={TEXT_SECONDARY} bold>{info.title}</Text>
{runningText ? <Text color={TEXT_DIM} italic>{runningText}</Text> : null} {runningText ? <Text color={TEXT_DIM} italic>{runningText}</Text> : null}
</Box> </Box>
{tabHint ? <Text color={TEXT_DIM} italic>{tabHint}</Text> : null}
</Box> </Box>
<Text color={borderColor} dimColor={dimBorder}> </Text> <Text color={borderColor} dimColor={dimBorder}> </Text>
</Box>, </Box>,
@@ -252,28 +250,55 @@ export function ToolCallCompact({
info, info,
indent, indent,
width, width,
keyPrefix,
showTabHint,
}: { }: {
info: ToolCallInfo; info: ToolCallInfo;
indent: number; indent: number;
width: number; width: number;
}) { keyPrefix: string;
showTabHint: boolean;
}): React.ReactNode[] {
const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!; const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙"; const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
const summary = summarizeContent(info); const summary = summarizeContent(info);
const maxSummaryWidth = width - indent - 12 - info.title.length; const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
const dimBorder = info.status !== "failed";
const cardWidth = Math.min(width - indent - 2, 72);
const innerWidth = cardWidth - 2;
const tabHintText = "tab ↔";
const maxSummaryWidth = innerWidth - info.title.length - 8 - (showTabHint ? tabHintText.length + 2 : 0);
const trimmedSummary = const trimmedSummary =
summary.length > maxSummaryWidth && maxSummaryWidth > 3 summary.length > maxSummaryWidth && maxSummaryWidth > 3
? summary.slice(0, maxSummaryWidth - 1) + "…" ? summary.slice(0, maxSummaryWidth - 1) + "…"
: summary; : summary;
return ( const topBorder = "╭" + "─".repeat(innerWidth) + "╮";
<Box marginLeft={indent} height={1}> const botBorder = "╰" + "─".repeat(innerWidth) + "╯";
<Text color={statusInfo.color}>{statusInfo.icon} </Text>
<Text>{kindIcon} </Text> return [
<Text color={TEXT_SECONDARY}>{info.title}</Text> <Box key={`${keyPrefix}-top`} marginLeft={indent} height={1}>
{trimmedSummary ? ( <Text color={borderColor} dimColor={dimBorder}>{topBorder}</Text>
<Text color={TEXT_DIM}> {trimmedSummary}</Text> </Box>,
) : null} <Box key={`${keyPrefix}-content`} marginLeft={indent} width={cardWidth} height={1}>
</Box> <Text color={borderColor} dimColor={dimBorder}> </Text>
); <Box flexGrow={1} justifyContent="space-between">
<Box>
<Text color={statusInfo.color}>{statusInfo.icon} </Text>
<Text>{kindIcon} </Text>
<Text color={TEXT_SECONDARY} bold>{info.title}</Text>
{trimmedSummary ? (
<Text color={TEXT_DIM}> {trimmedSummary}</Text>
) : null}
</Box>
{showTabHint && <Text color={TEXT_DIM} italic>{tabHintText}</Text>}
</Box>
<Text color={borderColor} dimColor={dimBorder}> </Text>
</Box>,
<Box key={`${keyPrefix}-bot`} marginLeft={indent} height={1}>
<Text color={borderColor} dimColor={dimBorder}>{botBorder}</Text>
</Box>,
];
} }
+20 -23
View File
@@ -319,7 +319,7 @@ function buildTurnBodyLines({
spinIdx, spinIdx,
pendingPermission, pendingPermission,
permissionIdx, permissionIdx,
expandedToolCall, toolCallsExpanded,
}: { }: {
turn: Turn; turn: Turn;
width: number; width: number;
@@ -328,32 +328,32 @@ function buildTurnBodyLines({
spinIdx: number; spinIdx: number;
pendingPermission: PendingPermission | null; pendingPermission: PendingPermission | null;
permissionIdx: number; permissionIdx: number;
expandedToolCall: string | null; toolCallsExpanded: boolean;
}): React.ReactNode[] { }): React.ReactNode[] {
const toolCallIds = turn.toolCallOrder; const toolCallIds = turn.toolCallOrder;
const toolCalls = turn.toolCalls; const toolCalls = turn.toolCalls;
const featuredId = findFeaturedToolCallId(toolCallIds, toolCalls);
const lines: React.ReactNode[] = []; const lines: React.ReactNode[] = [];
lines.push(<Box key="gap-top" height={1} />); lines.push(<Box key="gap-top" height={1} />);
for (const tcId of toolCallIds) { for (let i = 0; i < toolCallIds.length; i++) {
const tcId = toolCallIds[i]!;
const tc = toolCalls.get(tcId); const tc = toolCalls.get(tcId);
if (!tc) continue; if (!tc) continue;
if (tcId === featuredId || expandedToolCall === tcId) { if (toolCallsExpanded) {
const cardLines = buildToolCallCardLines(tc, CONTENT_INDENT, width, expandedToolCall === tcId, `tc-${tcId}`); const cardLines = buildToolCallCardLines(tc, CONTENT_INDENT, width, true, `tc-${tcId}`);
lines.push(...cardLines); lines.push(...cardLines);
} else { } else {
lines.push( const compactLines = ToolCallCompact({
<ToolCallCompact info: tc,
key={`tc-${tcId}`} indent: CONTENT_INDENT,
info={tc} width,
indent={CONTENT_INDENT} keyPrefix: `tc-${tcId}`,
width={width} showTabHint: i === 0,
/>, });
); lines.push(...compactLines);
} }
} }
@@ -566,7 +566,7 @@ function App({
const [queuedMessages, setQueuedMessages] = useState<string[]>([]); const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
const [viewTurnIdx, setViewTurnIdx] = useState(-1); const [viewTurnIdx, setViewTurnIdx] = useState(-1);
const [expandedToolCall, setExpandedToolCall] = useState<string | null>(null); const [toolCallsExpanded, setToolCallsExpanded] = useState(false);
const [scrollOffset, setScrollOffset] = useState(0); const [scrollOffset, setScrollOffset] = useState(0);
const clientRef = useRef<GooseClient | null>(null); const clientRef = useRef<GooseClient | null>(null);
@@ -589,7 +589,7 @@ function App({
}, [turns]); }, [turns]);
useEffect(() => { useEffect(() => {
setExpandedToolCall(null); setToolCallsExpanded(false);
setScrollOffset(0); setScrollOffset(0);
}, [viewTurnIdx, turns.length]); }, [viewTurnIdx, turns.length]);
@@ -684,7 +684,7 @@ function App({
}, },
]); ]);
setViewTurnIdx(-1); setViewTurnIdx(-1);
setExpandedToolCall(null); setToolCallsExpanded(false);
setScrollOffset(0); setScrollOffset(0);
}, []); }, []);
@@ -874,7 +874,7 @@ function App({
if (!trimmed) return; if (!trimmed) return;
setInput(""); setInput("");
setViewTurnIdx(-1); setViewTurnIdx(-1);
setExpandedToolCall(null); setToolCallsExpanded(false);
setScrollOffset(0); setScrollOffset(0);
if (loading || isProcessingRef.current) { if (loading || isProcessingRef.current) {
@@ -933,10 +933,7 @@ function App({
const currentTurn = turns[effectiveIdx]; const currentTurn = turns[effectiveIdx];
if (!currentTurn || currentTurn.toolCallOrder.length === 0) return; if (!currentTurn || currentTurn.toolCallOrder.length === 0) return;
const featuredId = findFeaturedToolCallId(currentTurn.toolCallOrder, currentTurn.toolCalls); setToolCallsExpanded((prev) => !prev);
if (!featuredId) return;
setExpandedToolCall((prev) => (prev === featuredId ? null : featuredId));
return; return;
} }
@@ -1019,7 +1016,7 @@ function App({
spinIdx, spinIdx,
pendingPermission: isLatest ? pendingPermission : null, pendingPermission: isLatest ? pendingPermission : null,
permissionIdx, permissionIdx,
expandedToolCall, toolCallsExpanded,
}); });
const allBodyLines = isLatest const allBodyLines = isLatest