Skip to content

Commit 0025978

Browse files
authored
feat(cli): support selective topic expansion and click-to-expand (#24793)
1 parent 4c5e887 commit 0025978

3 files changed

Lines changed: 202 additions & 14 deletions

File tree

packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ToolGroupMessage } from './ToolGroupMessage.js';
1010
import {
1111
UPDATE_TOPIC_TOOL_NAME,
1212
TOPIC_PARAM_TITLE,
13+
TOPIC_PARAM_SUMMARY,
1314
TOPIC_PARAM_STRATEGIC_INTENT,
1415
makeFakeConfig,
1516
CoreToolCallStatus,
@@ -292,7 +293,7 @@ describe('<ToolGroupMessage />', () => {
292293
name: UPDATE_TOPIC_TOOL_NAME,
293294
args: {
294295
[TOPIC_PARAM_TITLE]: 'Testing Topic',
295-
summary: 'This is the summary',
296+
[TOPIC_PARAM_SUMMARY]: 'This is the summary',
296297
},
297298
}),
298299
];
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { TopicMessage } from './TopicMessage.js';
9+
import { renderWithProviders } from '../../../test-utils/render.js';
10+
import {
11+
TOPIC_PARAM_TITLE,
12+
TOPIC_PARAM_SUMMARY,
13+
TOPIC_PARAM_STRATEGIC_INTENT,
14+
CoreToolCallStatus,
15+
UPDATE_TOPIC_TOOL_NAME,
16+
} from '@google/gemini-cli-core';
17+
18+
describe('<TopicMessage />', () => {
19+
const baseArgs = {
20+
[TOPIC_PARAM_TITLE]: 'Test Topic',
21+
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the strategic intent.',
22+
[TOPIC_PARAM_SUMMARY]:
23+
'This is the detailed summary that should be expandable.',
24+
};
25+
26+
const renderTopic = async (
27+
args: Record<string, unknown>,
28+
height?: number,
29+
toolActions?: {
30+
isExpanded?: (callId: string) => boolean;
31+
toggleExpansion?: (callId: string) => void;
32+
},
33+
) =>
34+
renderWithProviders(
35+
<TopicMessage
36+
args={args}
37+
terminalWidth={80}
38+
availableTerminalHeight={height}
39+
callId="test-topic"
40+
name={UPDATE_TOPIC_TOOL_NAME}
41+
description="Updating topic"
42+
status={CoreToolCallStatus.Success}
43+
confirmationDetails={undefined}
44+
resultDisplay={undefined}
45+
/>,
46+
{ toolActions, mouseEventsEnabled: true },
47+
);
48+
49+
it('renders title and intent by default (collapsed)', async () => {
50+
const { lastFrame } = await renderTopic(baseArgs, 40);
51+
const frame = lastFrame();
52+
expect(frame).toContain('Test Topic:');
53+
expect(frame).toContain('This is the strategic intent.');
54+
expect(frame).not.toContain('This is the detailed summary');
55+
expect(frame).not.toContain('(ctrl+o to expand)');
56+
});
57+
58+
it('renders summary when globally expanded (Ctrl+O)', async () => {
59+
const { lastFrame } = await renderTopic(baseArgs, undefined);
60+
const frame = lastFrame();
61+
expect(frame).toContain('Test Topic:');
62+
expect(frame).toContain('This is the strategic intent.');
63+
expect(frame).toContain('This is the detailed summary');
64+
expect(frame).not.toContain('(ctrl+o to collapse)');
65+
});
66+
67+
it('renders summary when selectively expanded via context', async () => {
68+
const isExpanded = vi.fn((id) => id === 'test-topic');
69+
const { lastFrame } = await renderTopic(baseArgs, 40, { isExpanded });
70+
const frame = lastFrame();
71+
expect(frame).toContain('Test Topic:');
72+
expect(frame).toContain('This is the detailed summary');
73+
expect(frame).not.toContain('(ctrl+o to collapse)');
74+
});
75+
76+
it('calls toggleExpansion when clicked', async () => {
77+
const toggleExpansion = vi.fn();
78+
const { simulateClick } = await renderTopic(baseArgs, 40, {
79+
toggleExpansion,
80+
});
81+
82+
// In renderWithProviders, the component is wrapped in a Box with terminalWidth.
83+
// The TopicMessage has marginLeft={2}.
84+
// So col 5 should definitely hit the text content.
85+
// row 1 is the first line of the TopicMessage.
86+
await simulateClick(5, 1);
87+
88+
expect(toggleExpansion).toHaveBeenCalledWith('test-topic');
89+
});
90+
91+
it('falls back to summary if strategic_intent is missing', async () => {
92+
const args = {
93+
[TOPIC_PARAM_TITLE]: 'Test Topic',
94+
[TOPIC_PARAM_SUMMARY]: 'Only summary is present.',
95+
};
96+
const { lastFrame } = await renderTopic(args, 40);
97+
const frame = lastFrame();
98+
expect(frame).toContain('Test Topic:');
99+
expect(frame).toContain('Only summary is present.');
100+
expect(frame).not.toContain('(ctrl+o to expand)');
101+
});
102+
103+
it('renders only strategic_intent if summary is missing', async () => {
104+
const args = {
105+
[TOPIC_PARAM_TITLE]: 'Test Topic',
106+
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Only intent is present.',
107+
};
108+
const { lastFrame } = await renderTopic(args, 40);
109+
const frame = lastFrame();
110+
expect(frame).toContain('Test Topic:');
111+
expect(frame).toContain('Only intent is present.');
112+
expect(frame).not.toContain('(ctrl+o to expand)');
113+
});
114+
});

packages/cli/src/ui/components/messages/TopicMessage.tsx

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
*/
66

77
import type React from 'react';
8-
import { Box, Text } from 'ink';
8+
import { useEffect, useId, useRef, useCallback } from 'react';
9+
import { Box, Text, type DOMElement } from 'ink';
910
import {
1011
UPDATE_TOPIC_TOOL_NAME,
1112
UPDATE_TOPIC_DISPLAY_NAME,
@@ -15,31 +16,103 @@ import {
1516
} from '@google/gemini-cli-core';
1617
import type { IndividualToolCallDisplay } from '../../types.js';
1718
import { theme } from '../../semantic-colors.js';
19+
import { useOverflowActions } from '../../contexts/OverflowContext.js';
20+
import { useToolActions } from '../../contexts/ToolActionsContext.js';
21+
import { useMouseClick } from '../../hooks/useMouseClick.js';
1822

1923
interface TopicMessageProps extends IndividualToolCallDisplay {
2024
terminalWidth: number;
25+
availableTerminalHeight?: number;
26+
isExpandable?: boolean;
2127
}
2228

2329
export const isTopicTool = (name: string): boolean =>
2430
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
2531

26-
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
32+
export const TopicMessage: React.FC<TopicMessageProps> = ({
33+
callId,
34+
args,
35+
availableTerminalHeight,
36+
isExpandable = true,
37+
}) => {
38+
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
39+
40+
// Expansion is active if either:
41+
// 1. The individual callId is expanded in the ToolActionsContext
42+
// 2. The entire turn is expanded (Ctrl+O) which sets availableTerminalHeight to undefined
43+
const isExpanded =
44+
(isExpandedInContext ? isExpandedInContext(callId) : false) ||
45+
availableTerminalHeight === undefined;
46+
47+
const overflowActions = useOverflowActions();
48+
const uniqueId = useId();
49+
const overflowId = `topic-${uniqueId}`;
50+
const containerRef = useRef<DOMElement>(null);
51+
2752
const rawTitle = args?.[TOPIC_PARAM_TITLE];
2853
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
29-
const rawIntent =
30-
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
31-
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
54+
55+
const rawStrategicIntent = args?.[TOPIC_PARAM_STRATEGIC_INTENT];
56+
const strategicIntent =
57+
typeof rawStrategicIntent === 'string' ? rawStrategicIntent : undefined;
58+
59+
const rawSummary = args?.[TOPIC_PARAM_SUMMARY];
60+
const summary = typeof rawSummary === 'string' ? rawSummary : undefined;
61+
62+
// Top line intent: prefer strategic_intent, fallback to summary
63+
const intent = strategicIntent || summary;
64+
65+
// Extra summary: only if both exist and are different (or just summary if we want to show it below)
66+
const hasExtraSummary = !!(
67+
strategicIntent &&
68+
summary &&
69+
strategicIntent !== summary
70+
);
71+
72+
const handleToggle = useCallback(() => {
73+
if (toggleExpansion && hasExtraSummary) {
74+
toggleExpansion(callId);
75+
}
76+
}, [toggleExpansion, hasExtraSummary, callId]);
77+
78+
useMouseClick(containerRef, handleToggle, {
79+
isActive: isExpandable && hasExtraSummary,
80+
});
81+
82+
useEffect(() => {
83+
// Only register if there is more content (summary) and it's currently hidden
84+
const hasHiddenContent = isExpandable && hasExtraSummary && !isExpanded;
85+
86+
if (hasHiddenContent && overflowActions) {
87+
overflowActions.addOverflowingId(overflowId);
88+
} else if (overflowActions) {
89+
overflowActions.removeOverflowingId(overflowId);
90+
}
91+
92+
return () => {
93+
overflowActions?.removeOverflowingId(overflowId);
94+
};
95+
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
3296

3397
return (
34-
<Box flexDirection="row" marginLeft={2} flexWrap="wrap">
35-
<Text color={theme.text.primary} bold wrap="truncate-end">
36-
{title || 'Topic'}
37-
{intent && <Text>: </Text>}
38-
</Text>
39-
{intent && (
40-
<Text color={theme.text.secondary} wrap="wrap">
41-
{intent}
98+
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
99+
<Box flexDirection="row" flexWrap="wrap">
100+
<Text color={theme.text.primary} bold wrap="truncate-end">
101+
{title || 'Topic'}
102+
{intent && <Text>: </Text>}
42103
</Text>
104+
{intent && (
105+
<Text color={theme.text.secondary} wrap="wrap">
106+
{intent}
107+
</Text>
108+
)}
109+
</Box>
110+
{isExpanded && hasExtraSummary && summary && (
111+
<Box marginTop={1} marginLeft={0}>
112+
<Text color={theme.text.secondary} wrap="wrap">
113+
{summary}
114+
</Text>
115+
</Box>
43116
)}
44117
</Box>
45118
);

0 commit comments

Comments
 (0)