From 0025978d76bc4607350bbfffaaadb838ee2f9f51 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Tue, 7 Apr 2026 08:00:40 -0700 Subject: [PATCH] feat(cli): support selective topic expansion and click-to-expand (#24793) --- .../messages/ToolGroupMessage.test.tsx | 3 +- .../components/messages/TopicMessage.test.tsx | 114 ++++++++++++++++++ .../ui/components/messages/TopicMessage.tsx | 99 +++++++++++++-- 3 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/TopicMessage.test.tsx diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 7108d76154..94584879f9 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -10,6 +10,7 @@ import { ToolGroupMessage } from './ToolGroupMessage.js'; import { UPDATE_TOPIC_TOOL_NAME, TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, TOPIC_PARAM_STRATEGIC_INTENT, makeFakeConfig, CoreToolCallStatus, @@ -292,7 +293,7 @@ describe('', () => { name: UPDATE_TOPIC_TOOL_NAME, args: { [TOPIC_PARAM_TITLE]: 'Testing Topic', - summary: 'This is the summary', + [TOPIC_PARAM_SUMMARY]: 'This is the summary', }, }), ]; diff --git a/packages/cli/src/ui/components/messages/TopicMessage.test.tsx b/packages/cli/src/ui/components/messages/TopicMessage.test.tsx new file mode 100644 index 0000000000..5da630cb86 --- /dev/null +++ b/packages/cli/src/ui/components/messages/TopicMessage.test.tsx @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { TopicMessage } from './TopicMessage.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, + CoreToolCallStatus, + UPDATE_TOPIC_TOOL_NAME, +} from '@google/gemini-cli-core'; + +describe('', () => { + const baseArgs = { + [TOPIC_PARAM_TITLE]: 'Test Topic', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the strategic intent.', + [TOPIC_PARAM_SUMMARY]: + 'This is the detailed summary that should be expandable.', + }; + + const renderTopic = async ( + args: Record, + height?: number, + toolActions?: { + isExpanded?: (callId: string) => boolean; + toggleExpansion?: (callId: string) => void; + }, + ) => + renderWithProviders( + , + { toolActions, mouseEventsEnabled: true }, + ); + + it('renders title and intent by default (collapsed)', async () => { + const { lastFrame } = await renderTopic(baseArgs, 40); + const frame = lastFrame(); + expect(frame).toContain('Test Topic:'); + expect(frame).toContain('This is the strategic intent.'); + expect(frame).not.toContain('This is the detailed summary'); + expect(frame).not.toContain('(ctrl+o to expand)'); + }); + + it('renders summary when globally expanded (Ctrl+O)', async () => { + const { lastFrame } = await renderTopic(baseArgs, undefined); + const frame = lastFrame(); + expect(frame).toContain('Test Topic:'); + expect(frame).toContain('This is the strategic intent.'); + expect(frame).toContain('This is the detailed summary'); + expect(frame).not.toContain('(ctrl+o to collapse)'); + }); + + it('renders summary when selectively expanded via context', async () => { + const isExpanded = vi.fn((id) => id === 'test-topic'); + const { lastFrame } = await renderTopic(baseArgs, 40, { isExpanded }); + const frame = lastFrame(); + expect(frame).toContain('Test Topic:'); + expect(frame).toContain('This is the detailed summary'); + expect(frame).not.toContain('(ctrl+o to collapse)'); + }); + + it('calls toggleExpansion when clicked', async () => { + const toggleExpansion = vi.fn(); + const { simulateClick } = await renderTopic(baseArgs, 40, { + toggleExpansion, + }); + + // In renderWithProviders, the component is wrapped in a Box with terminalWidth. + // The TopicMessage has marginLeft={2}. + // So col 5 should definitely hit the text content. + // row 1 is the first line of the TopicMessage. + await simulateClick(5, 1); + + expect(toggleExpansion).toHaveBeenCalledWith('test-topic'); + }); + + it('falls back to summary if strategic_intent is missing', async () => { + const args = { + [TOPIC_PARAM_TITLE]: 'Test Topic', + [TOPIC_PARAM_SUMMARY]: 'Only summary is present.', + }; + const { lastFrame } = await renderTopic(args, 40); + const frame = lastFrame(); + expect(frame).toContain('Test Topic:'); + expect(frame).toContain('Only summary is present.'); + expect(frame).not.toContain('(ctrl+o to expand)'); + }); + + it('renders only strategic_intent if summary is missing', async () => { + const args = { + [TOPIC_PARAM_TITLE]: 'Test Topic', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'Only intent is present.', + }; + const { lastFrame } = await renderTopic(args, 40); + const frame = lastFrame(); + expect(frame).toContain('Test Topic:'); + expect(frame).toContain('Only intent is present.'); + expect(frame).not.toContain('(ctrl+o to expand)'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/TopicMessage.tsx b/packages/cli/src/ui/components/messages/TopicMessage.tsx index 0aea7f5dbd..e58e60f6e1 100644 --- a/packages/cli/src/ui/components/messages/TopicMessage.tsx +++ b/packages/cli/src/ui/components/messages/TopicMessage.tsx @@ -5,7 +5,8 @@ */ import type React from 'react'; -import { Box, Text } from 'ink'; +import { useEffect, useId, useRef, useCallback } from 'react'; +import { Box, Text, type DOMElement } from 'ink'; import { UPDATE_TOPIC_TOOL_NAME, UPDATE_TOPIC_DISPLAY_NAME, @@ -15,31 +16,103 @@ import { } from '@google/gemini-cli-core'; import type { IndividualToolCallDisplay } from '../../types.js'; import { theme } from '../../semantic-colors.js'; +import { useOverflowActions } from '../../contexts/OverflowContext.js'; +import { useToolActions } from '../../contexts/ToolActionsContext.js'; +import { useMouseClick } from '../../hooks/useMouseClick.js'; interface TopicMessageProps extends IndividualToolCallDisplay { terminalWidth: number; + availableTerminalHeight?: number; + isExpandable?: boolean; } export const isTopicTool = (name: string): boolean => name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME; -export const TopicMessage: React.FC = ({ args }) => { +export const TopicMessage: React.FC = ({ + callId, + args, + availableTerminalHeight, + isExpandable = true, +}) => { + const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions(); + + // Expansion is active if either: + // 1. The individual callId is expanded in the ToolActionsContext + // 2. The entire turn is expanded (Ctrl+O) which sets availableTerminalHeight to undefined + const isExpanded = + (isExpandedInContext ? isExpandedInContext(callId) : false) || + availableTerminalHeight === undefined; + + const overflowActions = useOverflowActions(); + const uniqueId = useId(); + const overflowId = `topic-${uniqueId}`; + const containerRef = useRef(null); + const rawTitle = args?.[TOPIC_PARAM_TITLE]; const title = typeof rawTitle === 'string' ? rawTitle : undefined; - const rawIntent = - args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY]; - const intent = typeof rawIntent === 'string' ? rawIntent : undefined; + + const rawStrategicIntent = args?.[TOPIC_PARAM_STRATEGIC_INTENT]; + const strategicIntent = + typeof rawStrategicIntent === 'string' ? rawStrategicIntent : undefined; + + const rawSummary = args?.[TOPIC_PARAM_SUMMARY]; + const summary = typeof rawSummary === 'string' ? rawSummary : undefined; + + // Top line intent: prefer strategic_intent, fallback to summary + const intent = strategicIntent || summary; + + // Extra summary: only if both exist and are different (or just summary if we want to show it below) + const hasExtraSummary = !!( + strategicIntent && + summary && + strategicIntent !== summary + ); + + const handleToggle = useCallback(() => { + if (toggleExpansion && hasExtraSummary) { + toggleExpansion(callId); + } + }, [toggleExpansion, hasExtraSummary, callId]); + + useMouseClick(containerRef, handleToggle, { + isActive: isExpandable && hasExtraSummary, + }); + + useEffect(() => { + // Only register if there is more content (summary) and it's currently hidden + const hasHiddenContent = isExpandable && hasExtraSummary && !isExpanded; + + if (hasHiddenContent && overflowActions) { + overflowActions.addOverflowingId(overflowId); + } else if (overflowActions) { + overflowActions.removeOverflowingId(overflowId); + } + + return () => { + overflowActions?.removeOverflowingId(overflowId); + }; + }, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]); return ( - - - {title || 'Topic'} - {intent && : } - - {intent && ( - - {intent} + + + + {title || 'Topic'} + {intent && : } + {intent && ( + + {intent} + + )} + + {isExpanded && hasExtraSummary && summary && ( + + + {summary} + + )} );