diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index afe716cf1c..4a8a098e4f 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -6,9 +6,7 @@ import type React from 'react'; import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import { Colors } from '../colors.js'; +import { Box } from 'ink'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useKeypress } from '../hooks/useKeypress.js'; import path from 'node:path'; @@ -107,239 +105,16 @@ export interface SessionBrowserState { const SESSIONS_PER_PAGE = 20; // Approximate total width reserved for non-message columns and separators // (prefix, index, message count, age, pipes, and padding) in a session row. -// If the SessionItem layout changes, update this accordingly. -const FIXED_SESSION_COLUMNS_WIDTH = 30; import { SearchModeDisplay } from './SessionBrowser/SearchModeDisplay.js'; -import { NavigationHelp } from './SessionBrowser/NavigationHelp.js'; import { SessionListHeader } from './SessionBrowser/SessionListHeader.js'; import { NoResultsDisplay } from './SessionBrowser/NoResultsDisplay.js'; import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js'; import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js'; import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js'; +import { SessionList } from './SessionBrowser/SessionList.js'; import { sortSessions, filterSessions } from './SessionBrowser/utils.js'; -/** - * Table header component with column labels and scroll indicators. - */ -const SessionTableHeader = ({ - state, -}: { - state: SessionBrowserState; -}): React.JSX.Element => ( - - {state.scrollOffset > 0 ? : ' '} - - - - Index - - - - - - Msgs - - - - - - Age - - - - - - {state.searchQuery ? 'Match' : 'Name'} - - - -); - -/** - * Match snippet display component for search results. - */ -const MatchSnippetDisplay = ({ - session, - textColor, -}: { - session: SessionInfo; - textColor: (color?: string) => string; -}): React.JSX.Element | null => { - if (!session.matchSnippets || session.matchSnippets.length === 0) { - return null; - } - - const firstMatch = session.matchSnippets[0]; - const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:'; - const roleColor = textColor( - firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue, - ); - - return ( - <> - - {rolePrefix}{' '} - - {firstMatch.before} - - {firstMatch.match} - - {firstMatch.after} - - ); -}; - -/** - * Individual session row component. - */ -const SessionItem = ({ - session, - state, - terminalWidth, - formatRelativeTime, -}: { - session: SessionInfo; - state: SessionBrowserState; - terminalWidth: number; - formatRelativeTime: (dateString: string, style: 'short' | 'long') => string; -}): React.JSX.Element => { - const originalIndex = - state.startIndex + state.visibleSessions.indexOf(session); - const isActive = originalIndex === state.activeIndex; - const isDisabled = session.isCurrentSession; - const textColor = (c: string = Colors.Foreground) => { - if (isDisabled) { - return Colors.Gray; - } - return isActive ? theme.ui.focus : c; - }; - - const prefix = isActive ? '❯ ' : ' '; - let additionalInfo = ''; - let matchDisplay = null; - - // Add "(current)" label for the current session - if (session.isCurrentSession) { - additionalInfo = ' (current)'; - } - - // Show match snippets if searching and matches exist - if ( - state.searchQuery && - session.matchSnippets && - session.matchSnippets.length > 0 - ) { - matchDisplay = ( - - ); - - if (session.matchCount && session.matchCount > 1) { - additionalInfo += ` (+${session.matchCount - 1} more)`; - } - } - - // Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly. - const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0; - const availableMessageWidth = Math.max( - 20, - terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta, - ); - - const truncatedMessage = - matchDisplay || - (session.displayName.length === 0 ? ( - - (No messages) - - ) : session.displayName.length > availableMessageWidth ? ( - session.displayName.slice(0, availableMessageWidth - 1) + '…' - ) : ( - session.displayName - )); - - return ( - - - {prefix} - - - - #{originalIndex + 1} - - - - {' '} - │{' '} - - - - {session.messageCount} - - - - {' '} - │{' '} - - - - {formatRelativeTime(session.lastUpdated, 'short')} - - - - {' '} - │{' '} - - - - {truncatedMessage} - {additionalInfo && ( - - {additionalInfo} - - )} - - - - ); -}; - -/** - * Session list container component. - */ -const SessionList = ({ - state, - formatRelativeTime, -}: { - state: SessionBrowserState; - formatRelativeTime: (dateString: string, style: 'short' | 'long') => string; -}): React.JSX.Element => ( - - {/* Table Header */} - - {!state.isSearchMode && } - - - - {state.visibleSessions.map((session) => ( - - ))} - - - {state.endIndex < state.totalSessions ? <>▼ : } - - -); - /** * Hook to manage all SessionBrowser state. */ diff --git a/packages/cli/src/ui/components/SessionBrowser/MatchSnippetDisplay.tsx b/packages/cli/src/ui/components/SessionBrowser/MatchSnippetDisplay.tsx new file mode 100644 index 0000000000..b38096cc36 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/MatchSnippetDisplay.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionInfo } from '../../../utils/sessionUtils.js'; + +/** + * Match snippet display component for search results. + */ +export const MatchSnippetDisplay = ({ + session, + textColor, +}: { + session: SessionInfo; + textColor: (color?: string) => string; +}): React.JSX.Element | null => { + if (!session.matchSnippets || session.matchSnippets.length === 0) { + return null; + } + + const firstMatch = session.matchSnippets[0]; + const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:'; + const roleColor = textColor( + firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue, + ); + + return ( + + + {rolePrefix}{' '} + + {firstMatch.before} + + {firstMatch.match} + + {firstMatch.after} + + ); +}; diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserList.test.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserList.test.tsx new file mode 100644 index 0000000000..126b32ba6d --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserList.test.tsx @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { SessionList } from './SessionList.js'; +import { SessionItem } from './SessionItem.js'; +import { SessionTableHeader } from './SessionTableHeader.js'; +import { MatchSnippetDisplay } from './MatchSnippetDisplay.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; +import type { SessionInfo } from '../../../utils/sessionUtils.js'; + +describe('SessionBrowser List Components', () => { + const mockSession: SessionInfo = { + id: '1', + file: 'session-1', + fileName: 'session-1.json', + startTime: new Date().toISOString(), + displayName: 'Test Session', + firstUserMessage: 'Test Session', + messageCount: 5, + lastUpdated: new Date().toISOString(), + isCurrentSession: false, + index: 1, + }; + + const mockState = { + totalSessions: 1, + startIndex: 0, + endIndex: 1, + visibleSessions: [mockSession], + activeIndex: 0, + scrollOffset: 0, + terminalWidth: 80, + searchQuery: '', + isSearchMode: false, + } as SessionBrowserState; + + it('SessionTableHeader renders correctly', async () => { + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('MatchSnippetDisplay returns null when no snippets', () => { + const { lastFrame } = render( + c || ''} />, + ); + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + + it('MatchSnippetDisplay renders correctly with snippets', async () => { + const sessionWithSnippets = { + ...mockSession, + matchSnippets: [ + { + role: 'user' as const, + before: 'hello ', + match: 'world', + after: ' !', + }, + ], + }; + const { lastFrame, waitUntilReady } = render( + c || ''} + />, + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionItem renders correctly', async () => { + const { lastFrame, waitUntilReady } = render( + '10m ago'} + />, + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionList renders correctly', async () => { + const { lastFrame, waitUntilReady } = render( + '10m ago'} />, + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionItem.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionItem.tsx new file mode 100644 index 0000000000..3cc14741f4 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionItem.tsx @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import { theme } from '../../semantic-colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; +import type { SessionInfo } from '../../../utils/sessionUtils.js'; +import { MatchSnippetDisplay } from './MatchSnippetDisplay.js'; + +const FIXED_SESSION_COLUMNS_WIDTH = 30; + +/** + * Individual session row component. + */ +export const SessionItem = ({ + session, + state, + terminalWidth, + formatRelativeTime, +}: { + session: SessionInfo; + state: SessionBrowserState; + terminalWidth: number; + formatRelativeTime: (dateString: string, style: 'short' | 'long') => string; +}): React.JSX.Element => { + const originalIndex = + state.startIndex + state.visibleSessions.indexOf(session); + const isActive = originalIndex === state.activeIndex; + const isDisabled = session.isCurrentSession; + const textColor = (c: string = Colors.Foreground) => { + if (isDisabled) { + return Colors.Gray; + } + return isActive ? theme.ui.focus : c; + }; + + const prefix = isActive ? '❯ ' : ' '; + let additionalInfo = ''; + let matchDisplay = null; + + // Add "(current)" label for the current session + if (session.isCurrentSession) { + additionalInfo = ' (current)'; + } + + // Show match snippets if searching and matches exist + if ( + state.searchQuery && + session.matchSnippets && + session.matchSnippets.length > 0 + ) { + matchDisplay = ( + + ); + + if (session.matchCount && session.matchCount > 1) { + additionalInfo += ` (+${session.matchCount - 1} more)`; + } + } + + // Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly. + const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0; + const availableMessageWidth = Math.max( + 20, + terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta, + ); + + const truncatedMessage = + matchDisplay || + (session.displayName.length === 0 ? ( + + (No messages) + + ) : session.displayName.length > availableMessageWidth ? ( + session.displayName.slice(0, availableMessageWidth - 1) + '…' + ) : ( + session.displayName + )); + + return ( + + + {prefix} + + + + #{originalIndex + 1} + + + + {' '} + │{' '} + + + + {session.messageCount} + + + + {' '} + │{' '} + + + + {formatRelativeTime(session.lastUpdated, 'short')} + + + + {' '} + │{' '} + + + + {truncatedMessage} + {additionalInfo && ( + + {additionalInfo} + + )} + + + + ); +}; diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionList.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionList.tsx new file mode 100644 index 0000000000..3a5e0774cb --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionList.tsx @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; +import { SessionItem } from './SessionItem.js'; +import { SessionTableHeader } from './SessionTableHeader.js'; +import { NavigationHelp } from './NavigationHelp.js'; + +/** + * Session list container component. + */ +export const SessionList = ({ + state, + formatRelativeTime, +}: { + state: SessionBrowserState; + formatRelativeTime: (dateString: string, style: 'short' | 'long') => string; +}): React.JSX.Element => ( + + {/* Table Header */} + + {!state.isSearchMode && } + + + + {state.visibleSessions.map((session) => ( + + ))} + + + {state.endIndex < state.totalSessions ? <>▼ : } + + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionTableHeader.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionTableHeader.tsx new file mode 100644 index 0000000000..1f9e94e9c8 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionTableHeader.tsx @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +/** + * Table header component with column labels and scroll indicators. + */ +export const SessionTableHeader = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + {state.scrollOffset > 0 ? : ' '} + + + + Index + + + + + + Msgs + + + + + + Age + + + + + + {state.searchQuery ? 'Match' : 'Name'} + + + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserList.test.tsx.snap b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserList.test.tsx.snap new file mode 100644 index 0000000000..4d1a59fbaf --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserList.test.tsx.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SessionBrowser List Components > MatchSnippetDisplay renders correctly with snippets 1`] = ` +"You: hello world ! +" +`; + +exports[`SessionBrowser List Components > SessionItem renders correctly 1`] = ` +"❯ #1 │ 5 │ 10m │ Test Session + ago +" +`; + +exports[`SessionBrowser List Components > SessionList renders correctly 1`] = ` +"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q +Sort: s Reverse: r First/Last: g/G + + Index │ Msgs │ Age │ Name +❯ #1 │ 5 │ 10m │ Test Session + ago +▼ +" +`; + +exports[`SessionBrowser List Components > SessionTableHeader renders correctly 1`] = ` +" + Index │ Msgs │ Age │ Name +" +`;