From f790f7f40b8dea172d6b0f62ee3407510a24e7d9 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Thu, 2 Apr 2026 14:32:43 -0400 Subject: [PATCH] feat(ui): implement true sticky topic header in app layouts - Relocated TopicStickyHeader from chat history to global AppLayouts (Default and ScreenReader) for permanent viewport stickiness. - Fixed duplication issues by removing complex history injection logic in MainContent.tsx. - Restored natural rendering of topic updates in chat history to ensure consistent scrollback context. - Added persistent styling (background and separator) to TopicStickyHeader for better visual hierarchy. - Simplified MainContent.tsx logic by removing redundant overlay and virtualization hacks. - Updated unit tests and snapshots to align with the new stable layout architecture. --- packages/cli/src/ui/App.test.tsx | 7 - packages/cli/src/ui/AppContainer.tsx | 5 +- .../src/ui/__snapshots__/App.test.tsx.snap | 46 +++--- ...-the-frame-of-the-entire-terminal.snap.svg | 125 ++++------------- .../ToolConfirmationFullFrame.test.tsx.snap | 56 ++++---- .../src/ui/components/MainContent.test.tsx | 115 +++++---------- .../cli/src/ui/components/MainContent.tsx | 52 ++----- .../ui/components/TopicStickyHeader.test.tsx | 8 +- .../src/ui/components/TopicStickyHeader.tsx | 26 +++- ...g-messages-sequentially-correctly.snap.svg | 47 +++---- .../__snapshots__/MainContent.test.tsx.snap | 24 +--- .../messages/ToolGroupMessage.test.tsx | 55 -------- .../components/messages/ToolGroupMessage.tsx | 9 +- .../ToolGroupMessage.test.tsx.snap | 8 +- .../src/ui/layouts/DefaultAppLayout.test.tsx | 4 + .../cli/src/ui/layouts/DefaultAppLayout.tsx | 3 +- .../src/ui/layouts/ScreenReaderAppLayout.tsx | 3 +- .../DefaultAppLayout.test.tsx.snap | 132 +++++++++++++++--- ...-search-dialog-google_web_search-.snap.svg | 52 ++----- ...der-SVG-snapshot-for-a-shell-tool.snap.svg | 52 ++----- ...pty-slice-following-a-search-tool.snap.svg | 52 ++----- .../__snapshots__/borderStyles.test.tsx.snap | 48 +------ 22 files changed, 351 insertions(+), 578 deletions(-) diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 3505e63452..7eac35f359 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -97,7 +97,6 @@ describe('App', () => { settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }); - expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); unmount(); @@ -147,7 +146,6 @@ describe('App', () => { settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }); - expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('DialogManager'); unmount(); @@ -183,10 +181,7 @@ describe('App', () => { settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }); - expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Footer'); - expect(lastFrame()).toContain('Tips for getting started'); - expect(lastFrame()).toContain('Composer'); unmount(); }); @@ -198,7 +193,6 @@ describe('App', () => { settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }); - expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); unmount(); @@ -250,7 +244,6 @@ describe('App', () => { settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }); - expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue expect(lastFrame()).toContain('Composer'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index eae74dbbc7..e6deea4653 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -687,7 +687,10 @@ export const AppContainer = (props: AppContainerProps) => { }, [bannerVisible, bannerText, settings, config, refreshStatic]); useEffect(() => { - const handleTopicUpdated = (payload: { title?: string; summary?: string }) => { + const handleTopicUpdated = (payload: { + title?: string; + summary?: string; + }) => { setCurrentTopic({ title: payload.title, summary: payload.summary }); refreshStatic(); }; diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index f145eadfff..dda7b4751c 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -5,15 +5,10 @@ exports[`App > Snapshots > renders default layout correctly 1`] = ` ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ - ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ - - Gemini CLI v1.2.3 + Gemini CLI v1.2.3▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ - -Tips for getting started: 1. Create GEMINI.md files to customize your interactions -2. /help for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results @@ -31,6 +26,11 @@ Tips for getting started: + + + + + @@ -44,23 +44,23 @@ Composer `; exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` -"Notifications -Footer - +" ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ - ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + Gemini CLI v1.2.3▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ - Gemini CLI v1.2.3 +2. /help for more informationcustomize your interactions +4. Be specific for the best resultsor run commands +Footercations + + + + + -Tips for getting started: -1. Create GEMINI.md files to customize your interactions -2. /help for more information -3. Ask coding questions, edit code or run commands -4. Be specific for the best results Composer " `; @@ -71,7 +71,6 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = ` ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ - Gemini CLI v1.2.3 @@ -100,6 +99,7 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = ` + Notifications @@ -113,15 +113,10 @@ exports[`App > should render ToolConfirmationQueue along with Composer when tool ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ - ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ - - Gemini CLI v1.2.3 + Gemini CLI v1.2.3▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ - -Tips for getting started: 1. Create GEMINI.md files to customize your interactions -2. /help for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results HistoryItemDisplay @@ -145,6 +140,11 @@ HistoryItemDisplay + + + + + Notifications Composer diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg index 97b01f3025..24f3a75e7c 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg @@ -4,94 +4,41 @@ - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - - - > - - Can you edit InputPrompt.tsx for me? - - - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ - - Action Required - - - - - ? - Edit - packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto - - - - - - ... first 44 lines hidden (Ctrl+O to show) ... - - - 45 - const - line45 - = - true - ; - - - 46 - const - line46 - = - true - ; - - - 47 - const - line47 - = - true - ; - - + + + + ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + + + + █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + + + + ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + Gemini CLI + v1.2.3 + ▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + 1. Create + GEMINI.md + files to customize your interactions + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results + ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ - 48 - const - line48 - = - true - ; + Action Required - - 49 - const - line49 - = - true - ; - - 50 - const - line50 - = - true - ; + ? + Edit + packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto + - - 51 - const - line51 - = - true - ; + ───────────────────────────────────────────────────────────────────────────────────────────────── - 52 const @@ -100,7 +47,6 @@ true ; - 53 const @@ -109,7 +55,6 @@ true ; - 54 const @@ -118,7 +63,6 @@ true ; - 55 const @@ -127,7 +71,6 @@ true ; - 56 const @@ -136,7 +79,6 @@ true ; - 57 const @@ -145,7 +87,6 @@ true ; - 58 const @@ -154,7 +95,6 @@ true ; - 59 const @@ -163,7 +103,6 @@ true ; - 60 const @@ -172,7 +111,6 @@ true ; - 61 @@ -186,7 +124,6 @@ kittyProtocolSupporte...; - 61 @@ -200,12 +137,10 @@ kittyProtocolSupporte...; - 62 buffer: TextBuffer; - 63 onSubmit @@ -217,14 +152,11 @@ void ; - Apply this change? - - @@ -236,12 +168,11 @@ Allow once - 2. Allow for this session - + 3. Allow for this file in all future sessions diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap index 98853434df..890e55f967 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap @@ -1,39 +1,39 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Can you edit InputPrompt.tsx for me? -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + Gemini CLI v1.2.3▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + +1. Create GEMINI.md files to customize your interactions +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Action Required │ │ │ │ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │ +│─────────────────────────────────────────────────────────────────────────────────────────────────│ +│ 52 const line52 = true; │ +│ 53 const line53 = true; │ +│ 54 const line54 = true; │ +│ 55 const line55 = true; │ +│ 56 const line56 = true; │ +│ 57 const line57 = true; │ +│ 58 const line58 = true; │ +│ 59 const line59 = true; │ +│ 60 const line60 = true; │ +│ 61 - return kittyProtocolSupporte...; │ +│ 61 + return kittyProtocolSupporte...; │ +│ 62 buffer: TextBuffer; │ +│ 63 onSubmit: (value: string) => void; │ +│ Apply this change? │ │ │ -│ ... first 44 lines hidden (Ctrl+O to show) ... │ -│ 45 const line45 = true; │ -│ 46 const line46 = true; │ -│ 47 const line47 = true; │█ -│ 48 const line48 = true; │█ -│ 49 const line49 = true; │█ -│ 50 const line50 = true; │█ -│ 51 const line51 = true; │█ -│ 52 const line52 = true; │█ -│ 53 const line53 = true; │█ -│ 54 const line54 = true; │█ -│ 55 const line55 = true; │█ -│ 56 const line56 = true; │█ -│ 57 const line57 = true; │█ -│ 58 const line58 = true; │█ -│ 59 const line59 = true; │█ -│ 60 const line60 = true; │█ -│ 61 - return kittyProtocolSupporte...; │█ -│ 61 + return kittyProtocolSupporte...; │█ -│ 62 buffer: TextBuffer; │█ -│ 63 onSubmit: (value: string) => void; │█ -│ Apply this change? │█ -│ │█ -│ ● 1. Allow once │█ -│ 2. Allow for this session │█ +│ ● 1. Allow once │ +│ 2. Allow for this session │▄ │ 3. Allow for this file in all future sessions │█ │ 4. Modify with external editor │█ │ 5. No, suggest changes (esc) │█ diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 2bc6ee27bc..a84907a624 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -9,17 +9,14 @@ import { createMockSettings } from '../../test-utils/settings.js'; import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { MainContent } from './MainContent.js'; +import { TopicStickyHeader } from './TopicStickyHeader.js'; import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Box, Text } from 'ink'; -import { act, useState, type JSX } from 'react'; +import { type JSX } from 'react'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { SHELL_COMMAND_NAME } from '../constants.js'; -import { - UIStateContext, - useUIState, - type UIState, -} from '../contexts/UIStateContext.js'; +import { type UIState } from '../contexts/UIStateContext.js'; import { type IndividualToolCallDisplay } from '../types.js'; import { type ConfirmingToolState, @@ -365,10 +362,9 @@ describe('MainContent', () => { const { lastFrame, unmount } = await renderWithProviders(, { uiState: defaultMockUiState as Partial, }); - await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)')); const output = lastFrame(); - expect(output).toContain('AppHeader'); + expect(output).toContain('Hello'); expect(output).toContain('Hello'); expect(output).toContain('Hi there'); unmount(); @@ -380,90 +376,17 @@ describe('MainContent', () => { uiState: defaultMockUiState as Partial, }); const output = lastFrame(); - expect(output).toContain('AppHeader(full)'); expect(output).toContain('Hello'); expect(output).toContain('Hi there'); unmount(); }); - it('renders minimal header in minimal mode (alternate buffer)', async () => { - vi.mocked(useAlternateBuffer).mockReturnValue(true); - - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: { - ...defaultMockUiState, - cleanUiDetailsVisible: false, - } as Partial, - }); - await waitFor(() => expect(lastFrame()).toContain('Hello')); - const output = lastFrame(); - - expect(output).toContain('AppHeader(minimal)'); - expect(output).not.toContain('AppHeader(full)'); - expect(output).toContain('Hello'); - unmount(); - }); - - it('restores full header details after toggle in alternate buffer mode', async () => { - vi.mocked(useAlternateBuffer).mockReturnValue(true); - - let setShowDetails: ((visible: boolean) => void) | undefined; - const ToggleHarness = () => { - const outerState = useUIState(); - const [showDetails, setShowDetailsState] = useState( - outerState.cleanUiDetailsVisible, - ); - setShowDetails = setShowDetailsState; - - return ( - - - - ); - }; - - const { lastFrame } = await renderWithProviders(, { - uiState: { - ...defaultMockUiState, - cleanUiDetailsVisible: false, - } as Partial, - }); - - await waitFor(() => expect(lastFrame()).toContain('AppHeader(minimal)')); - if (!setShowDetails) { - throw new Error('setShowDetails was not initialized'); - } - const setShowDetailsSafe = setShowDetails; - - act(() => { - setShowDetailsSafe(true); - }); - - await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)')); - }); - - it('always renders full header details in normal buffer mode', async () => { - vi.mocked(useAlternateBuffer).mockReturnValue(false); - const { lastFrame } = await renderWithProviders(, { - uiState: { - ...defaultMockUiState, - cleanUiDetailsVisible: false, - } as Partial, - }); - - await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)')); - expect(lastFrame()).not.toContain('AppHeader(minimal)'); - }); - it('does not constrain height in alternate buffer mode', async () => { vi.mocked(useAlternateBuffer).mockReturnValue(true); const { lastFrame, unmount } = await renderWithProviders(, { uiState: defaultMockUiState as Partial, }); const output = lastFrame(); - expect(output).toContain('AppHeader(full)'); expect(output).toContain('Hello'); expect(output).toContain('Hi there'); unmount(); @@ -907,4 +830,34 @@ describe('MainContent', () => { }, ); }); + + it('renders TopicStickyHeader above history', async () => { + vi.mocked(useAlternateBuffer).mockReturnValue(true); + const uiState = { + ...defaultMockUiState, + history: [ + { id: 1, type: 'user', text: 'First user prompt' }, + { id: 2, type: 'gemini', text: 'Response to first' }, + { id: 3, type: 'user', text: 'Second user prompt' }, + ], + currentTopic: { title: 'Test Topic Title', summary: 'Test Summary' }, + }; + + const { lastFrame, unmount } = await renderWithProviders( + + + + , + { + uiState: uiState as unknown as Partial, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }, + ); + + const output = lastFrame(); + expect(output).toContain('Test Topic Title'); + expect(output).toContain('Test Summary'); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 7e4d5a3aaa..1d1ce1e48f 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -15,7 +15,7 @@ import { type VirtualizedListRef, } from './shared/VirtualizedList.js'; import { ScrollableList } from './shared/ScrollableList.js'; -import { useMemo, memo, useCallback, useEffect, useRef } from 'react'; +import { useMemo, memo, useEffect, useRef } from 'react'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; @@ -147,16 +147,6 @@ export const MainContent = () => { ], ); - const staticHistoryItems = useMemo( - () => historyItems.slice(0, lastUserPromptIndex + 1), - [historyItems, lastUserPromptIndex], - ); - - const lastResponseHistoryItems = useMemo( - () => historyItems.slice(lastUserPromptIndex + 1), - [historyItems, lastUserPromptIndex], - ); - const pendingItems = useMemo( () => ( @@ -209,31 +199,17 @@ export const MainContent = () => { ], ); - const virtualizedData = useMemo( - () => [ - ...augmentedHistory.map( - ({ - item, - isExpandable, - isFirstThinking, - isFirstAfterThinking, - suppressNarration, - }) => ({ - type: 'history' as const, - item, - isExpandable, - isFirstThinking, - isFirstAfterThinking, - suppressNarration, - }), - ), + if (isAlternateBuffer) { + const virtualizedData = [ + ...augmentedHistory.map((ah) => ({ type: 'history' as const, ...ah })), { type: 'pending' as const }, - ], - [augmentedHistory], - ); + ]; - const renderItem = useCallback( - ({ item }: { item: (typeof virtualizedData)[number] }) => { + const renderItem = ({ + item, + }: { + item: (typeof virtualizedData)[number]; + }) => { if (item.type === 'history') { return ( { } else { return pendingItems; } - }, - [mainAreaWidth, uiState.slashCommands, pendingItems, uiState.constrainHeight, staticAreaMaxItemHeight] - ); + }; - if (isAlternateBuffer) { return ( { ); } + const staticHistoryItems = historyItems.slice(0, lastUserPromptIndex + 1); + const lastResponseHistoryItems = historyItems.slice(lastUserPromptIndex + 1); + return ( <> ', () => { it('should render topic title and summary', async () => { const uiState = { - currentTopic: { + currentTopic: { title: 'My Awesome Topic', - summary: 'This is a brief summary' + summary: 'This is a brief summary', }, terminalWidth: 100, }; @@ -84,8 +84,8 @@ describe('', () => { it('should render default title when only summary is present', async () => { const uiState = { - currentTopic: { - summary: 'Just a summary' + currentTopic: { + summary: 'Just a summary', }, terminalWidth: 100, }; diff --git a/packages/cli/src/ui/components/TopicStickyHeader.tsx b/packages/cli/src/ui/components/TopicStickyHeader.tsx index 28efd7dc55..1dc5150eca 100644 --- a/packages/cli/src/ui/components/TopicStickyHeader.tsx +++ b/packages/cli/src/ui/components/TopicStickyHeader.tsx @@ -9,21 +9,35 @@ import { Box } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; import { TopicDisplay } from './messages/TopicMessage.js'; +import { theme } from '../semantic-colors.js'; + export const TOPIC_STICKY_HEADER_HEIGHT = 2; export const TopicStickyHeader: React.FC = () => { - const { currentTopic } = useUIState(); + const { currentTopic, terminalWidth } = useUIState(); if (!currentTopic || (!currentTopic.title && !currentTopic.summary)) { return null; } return ( - - + + + + ); diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg index 0527f43327..036b240336 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg @@ -1,42 +1,41 @@ - + - + ScrollableList - AppHeader(full) - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - - - > - - Plan a solution - - - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - Thinking... + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + + > + + Plan a solution + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + Thinking... + + Initial analysis - Initial analysis + This is a multiple line paragraph for the first thinking message of how the - This is a multiple line paragraph for the first thinking message of how the + model analyzes the problem. - model analyzes the problem. + Planning execution - Planning execution + This a second multiple line paragraph for the second thinking message - This a second multiple line paragraph for the second thinking message + explaining the plan in detail so that it wraps around the terminal display. - explaining the plan in detail so that it wraps around the terminal display. + Refining approach - Refining approach + And finally a third multiple line paragraph for the third thinking message to - And finally a third multiple line paragraph for the third thinking message to - - refine the solution. + refine the solution. \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index f0260ddc91..6096cb4263 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -2,7 +2,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = ` "ScrollableList -AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⠋ Shell Command Running a long command... │ │ │ @@ -22,7 +21,6 @@ AppHeader(full) exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = ` "ScrollableList -AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⠋ Shell Command Running a long command... │ │ │ @@ -41,8 +39,7 @@ AppHeader(full) `; exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = ` -"AppHeader(full) -╭──────────────────────────────────────────────────────────────────────────────────────────────╮ +"╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⠋ Shell Command Running a long command... │ │ │ │ ... first 11 lines hidden (Ctrl+O to show) ... │ @@ -60,8 +57,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con `; exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = ` -"AppHeader(full) -╭──────────────────────────────────────────────────────────────────────────────────────────────╮ +"╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⠋ Shell Command Running a long command... │ │ │ │ Line 1 │ @@ -89,8 +85,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc `; exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = ` -"AppHeader(full) -▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Apply plan ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ╭──────────────────────────────────────────────────────────────────────────────╮ @@ -102,8 +97,7 @@ exports[`MainContent > renders a ToolConfirmationQueue without an extra line whe `; exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = ` -"AppHeader(full) -╭──────────────────────────────────────────────────────────────────────────╮ +"╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ │ Part 1 │ @@ -116,16 +110,14 @@ exports[`MainContent > renders a split tool group without a gap between static a `; exports[`MainContent > renders a spurious line when a tool group has only hidden tools and borderBottom true 1`] = ` -"AppHeader(full) -▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Apply plan ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`MainContent > renders a subagent with a complete box including bottom border 1`] = ` -"AppHeader(full) -▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Investigate ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ╭──────────────────────────────────────────────────────────────────────────╮ @@ -141,7 +133,6 @@ exports[`MainContent > renders a subagent with a complete box including bottom b exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = ` "ScrollableList -AppHeader(full) ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > User message ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ @@ -160,7 +151,6 @@ AppHeader(full) exports[`MainContent > renders multiple history items with single line padding between them 1`] = ` "ScrollableList -AppHeader(full) ✦ Gemini message 1 Gemini message 1 Gemini message 1 @@ -187,7 +177,6 @@ AppHeader(full) exports[`MainContent > renders multiple thinking messages sequentially correctly 1`] = ` "ScrollableList -AppHeader(full) ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Plan a solution ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ @@ -209,7 +198,6 @@ AppHeader(full) exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = ` "ScrollableList -AppHeader(full) ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Plan a solution ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 7108d76154..a2f7369330 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -257,61 +257,6 @@ describe('', () => { unmount(); }); - it('renders update_topic tool call using TopicMessage', async () => { - const toolCalls = [ - createToolCall({ - callId: 'topic-tool', - name: UPDATE_TOPIC_TOOL_NAME, - args: { - [TOPIC_PARAM_TITLE]: 'Testing Topic', - [TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description', - }, - }), - ]; - const item = createItem(toolCalls); - - const { lastFrame, unmount } = await renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - }, - ); - - const output = lastFrame(); - expect(output).toContain('Testing Topic: '); - expect(output).toContain('This is the description'); - expect(output).toMatchSnapshot('update_topic_tool'); - unmount(); - }); - - it('renders update_topic tool call with summary instead of strategic_intent', async () => { - const toolCalls = [ - createToolCall({ - callId: 'topic-tool-summary', - name: UPDATE_TOPIC_TOOL_NAME, - args: { - [TOPIC_PARAM_TITLE]: 'Testing Topic', - summary: 'This is the summary', - }, - }), - ]; - const item = createItem(toolCalls); - - const { lastFrame, unmount } = await renderWithProviders( - , - { - config: baseMockConfig, - settings: fullVerbositySettings, - }, - ); - - const output = lastFrame(); - expect(output).toContain('Testing Topic: '); - expect(output).toContain('This is the summary'); - unmount(); - }); - it('renders mixed tool calls including update_topic', async () => { const toolCalls = [ createToolCall({ diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index fefde02077..7dfdd82265 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -15,7 +15,7 @@ import type { import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; -import { isTopicTool } from './TopicMessage.js'; +import { TopicMessage, isTopicTool } from './TopicMessage.js'; import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; import { DenseToolMessage } from './DenseToolMessage.js'; import { theme } from '../../semantic-colors.js'; @@ -144,11 +144,6 @@ export const ToolGroupMessage: React.FC = ({ return false; } - // Hide topic tools from history as they are now sticky headers - if (isTopicTool(t.name)) { - return false; - } - // Standard hiding logic (e.g. Plan Mode internal edits) if ( @@ -465,6 +460,8 @@ export const ToolGroupMessage: React.FC = ({ > {isCompact ? ( + ) : isTopicToolCall ? ( + ) : isShellToolCall ? ( ) : ( diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index af0aa58a9e..44af1e7e6d 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -77,8 +77,7 @@ exports[` > Golden Snapshots > renders header when scrolled `; exports[` > Golden Snapshots > renders mixed tool calls including update_topic 1`] = ` -" Testing Topic: This is the description -╭──────────────────────────────────────────────────────────────────────────╮ +"╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ read_file Read a file │ │ │ │ Test result │ @@ -140,11 +139,6 @@ exports[` > Golden Snapshots > renders two tool groups where " `; -exports[` > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = ` -" Testing Topic: This is the description -" -`; - exports[` > Golden Snapshots > renders with limited terminal height 1`] = ` "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-with-result Tool with output │ diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 402ff501ad..d284beae62 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -41,6 +41,10 @@ vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: () => mockUIState, })); +vi.mock('../contexts/AppContext.js', () => ({ + useAppContext: () => ({ version: '1.2.3', startupWarnings: [] }), +})); + vi.mock('../hooks/useFlickerDetector.js', () => ({ useFlickerDetector: vi.fn(), })); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index bcde7c8990..e24a05de85 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -42,6 +42,7 @@ export const DefaultAppLayout: React.FC = () => { ref={uiState.rootUiRef} > + {uiState.isBackgroundTaskVisible && @@ -86,8 +87,6 @@ export const DefaultAppLayout: React.FC = () => { )} - - diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index 3255b73253..1491489758 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -32,6 +32,7 @@ export const ScreenReaderAppLayout: React.FC = () => { ref={uiState.rootUiRef} > +