From b97661553fcfe1bbb554c90b92a8d13312fa6c65 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:16:56 -0700 Subject: [PATCH] feat(ui): build interactive session browser component (#13351) --- .../src/ui/components/SessionBrowser.test.tsx | 371 +++++++ .../cli/src/ui/components/SessionBrowser.tsx | 933 ++++++++++++++++++ .../src/ui/hooks/useSessionBrowser.test.ts | 785 ++++----------- .../cli/src/ui/hooks/useSessionBrowser.ts | 101 +- packages/cli/src/utils/sessionCleanup.test.ts | 38 + packages/cli/src/utils/sessionUtils.test.ts | 69 +- packages/cli/src/utils/sessionUtils.ts | 166 +++- packages/cli/src/utils/sessions.test.ts | 42 + packages/cli/src/utils/sessions.ts | 6 +- 9 files changed, 1907 insertions(+), 604 deletions(-) create mode 100644 packages/cli/src/ui/components/SessionBrowser.test.tsx create mode 100644 packages/cli/src/ui/components/SessionBrowser.tsx diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx new file mode 100644 index 0000000000..e063af40aa --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx @@ -0,0 +1,371 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import type { Config } from '@google/gemini-cli-core'; +import { SessionBrowser } from './SessionBrowser.js'; +import type { SessionBrowserProps } from './SessionBrowser.js'; +import type { SessionInfo } from '../../utils/sessionUtils.js'; + +// Collect key handlers registered via useKeypress so tests can +// simulate input without going through the full stdin pipeline. +const keypressHandlers: Array<(key: unknown) => void> = []; + +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80, rows: 24 }), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + // The real hook subscribes to the KeypressContext. Here we just + // capture the handler so tests can call it directly. + useKeypress: ( + handler: (key: unknown) => void, + options: { isActive: boolean }, + ) => { + if (options?.isActive) { + keypressHandlers.push(handler); + } + }, +})); + +// Mock the component itself to bypass async loading +vi.mock('./SessionBrowser.js', async (importOriginal) => { + const original = await importOriginal(); + const React = await import('react'); + + const TestSessionBrowser = ( + props: SessionBrowserProps & { + testSessions?: SessionInfo[]; + testError?: string | null; + }, + ) => { + const state = original.useSessionBrowserState( + props.testSessions || [], + false, // Not loading + props.testError || null, + ); + const moveSelection = original.useMoveSelection(state); + const cycleSortOrder = original.useCycleSortOrder(state); + original.useSessionBrowserInput( + state, + moveSelection, + cycleSortOrder, + props.onResumeSession, + props.onDeleteSession, + props.onExit, + ); + + return React.createElement(original.SessionBrowserView, { state }); + }; + + return { + ...original, + SessionBrowser: TestSessionBrowser, + }; +}); + +// Cast SessionBrowser to a type that includes the test-only props so TypeScript doesn't complain +const TestSessionBrowser = SessionBrowser as unknown as React.FC< + SessionBrowserProps & { + testSessions?: SessionInfo[]; + testError?: string | null; + } +>; + +const createMockConfig = (overrides: Partial = {}): Config => + ({ + storage: { + getProjectTempDir: () => '/tmp/test', + }, + getSessionId: () => 'default-session-id', + ...overrides, + }) as Config; + +const triggerKey = ( + partialKey: Partial<{ + name: string; + ctrl: boolean; + meta: boolean; + shift: boolean; + paste: boolean; + insertable: boolean; + sequence: string; + }>, +) => { + const handler = keypressHandlers[keypressHandlers.length - 1]; + if (!handler) { + throw new Error('No keypress handler registered'); + } + + const key = { + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + insertable: false, + sequence: '', + ...partialKey, + }; + + act(() => { + handler(key); + }); +}; + +const createSession = (overrides: Partial): SessionInfo => ({ + id: 'session-id', + file: 'session-id', + fileName: 'session-id.json', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messageCount: 1, + displayName: 'Test Session', + firstUserMessage: 'Test Session', + isCurrentSession: false, + index: 0, + ...overrides, +}); + +describe('SessionBrowser component', () => { + beforeEach(() => { + keypressHandlers.length = 0; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows empty state when no sessions exist', () => { + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('No auto-saved conversations found.'); + expect(lastFrame()).toContain('Press q to exit'); + }); + + it('renders a list of sessions and marks current session as disabled', () => { + const session1 = createSession({ + id: 'abc123', + file: 'abc123', + displayName: 'First conversation about cats', + lastUpdated: '2025-01-01T10:05:00Z', + messageCount: 2, + index: 0, + }); + const session2 = createSession({ + id: 'def456', + file: 'def456', + displayName: 'Second conversation about dogs', + lastUpdated: '2025-01-01T11:30:00Z', + messageCount: 5, + isCurrentSession: true, + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + const output = lastFrame(); + expect(output).toContain('Chat Sessions (2 total'); + expect(output).toContain('First conversation about cats'); + expect(output).toContain('Second conversation about dogs'); + expect(output).toContain('(current)'); + }); + + it('enters search mode, filters sessions, and renders match snippets', async () => { + const searchSession = createSession({ + id: 'search1', + file: 'search1', + displayName: 'Query is here and another query.', + firstUserMessage: 'Query is here and another query.', + fullContent: 'Query is here and another query.', + messages: [ + { + role: 'user', + content: 'Query is here and another query.', + }, + ], + index: 0, + }); + + const otherSession = createSession({ + id: 'other', + file: 'other', + displayName: 'Nothing interesting here.', + firstUserMessage: 'Nothing interesting here.', + fullContent: 'Nothing interesting here.', + messages: [ + { + role: 'user', + content: 'Nothing interesting here.', + }, + ], + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Chat Sessions (2 total'); + + // Enter search mode. + triggerKey({ sequence: '/', name: '/' }); + + await waitFor(() => { + expect(lastFrame()).toContain('Search:'); + }); + + // Type the query "query". + for (const ch of ['q', 'u', 'e', 'r', 'y']) { + triggerKey({ sequence: ch, name: ch, ctrl: false, meta: false }); + } + + await waitFor(() => { + const output = lastFrame(); + expect(output).toContain('Chat Sessions (1 total, filtered'); + expect(output).toContain('Query is here'); + expect(output).not.toContain('Nothing interesting here.'); + + expect(output).toContain('You:'); + expect(output).toContain('query'); + expect(output).toContain('(+1 more)'); + }); + }); + + it('handles keyboard navigation and resumes the selected session', () => { + const session1 = createSession({ + id: 'one', + file: 'one', + displayName: 'First session', + index: 0, + }); + const session2 = createSession({ + id: 'two', + file: 'two', + displayName: 'Second session', + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Chat Sessions (2 total'); + + // Move selection down. + triggerKey({ name: 'down', sequence: '[B' }); + + // Press Enter. + triggerKey({ name: 'return', sequence: '\r' }); + + expect(onResumeSession).toHaveBeenCalledTimes(1); + const [resumedSession] = onResumeSession.mock.calls[0]; + expect(resumedSession).toEqual(session2); + }); + + it('does not allow resuming or deleting the current session', () => { + const currentSession = createSession({ + id: 'current', + file: 'current', + displayName: 'Current session', + isCurrentSession: true, + index: 0, + }); + const otherSession = createSession({ + id: 'other', + file: 'other', + displayName: 'Other session', + isCurrentSession: false, + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onDeleteSession = vi.fn(); + const onExit = vi.fn(); + + render( + , + ); + + // Active selection is at 0 (current session). + triggerKey({ name: 'return', sequence: '\r' }); + expect(onResumeSession).not.toHaveBeenCalled(); + + // Attempt delete. + triggerKey({ sequence: 'x', name: 'x' }); + expect(onDeleteSession).not.toHaveBeenCalled(); + }); + + it('shows an error state when loading sessions fails', () => { + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + const output = lastFrame(); + expect(output).toContain('Error: storage failure'); + expect(output).toContain('Press q to exit'); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx new file mode 100644 index 0000000000..ff52945f7b --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -0,0 +1,933 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../colors.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import path from 'node:path'; +import type { Config } from '@google/gemini-cli-core'; +import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js'; +import { + cleanMessage, + formatRelativeTime, + getSessionFiles, +} from '../../utils/sessionUtils.js'; + +/** + * Props for the main SessionBrowser component. + */ +export interface SessionBrowserProps { + /** Application configuration object */ + config: Config; + /** Callback when user selects a session to resume */ + onResumeSession: (session: SessionInfo) => void; + /** Callback when user deletes a session */ + onDeleteSession?: (session: SessionInfo) => void; + /** Callback when user exits the session browser */ + onExit: () => void; +} + +/** + * Centralized state interface for SessionBrowser component. + * Eliminates prop drilling by providing all state in a single object. + */ +export interface SessionBrowserState { + // Data state + /** All loaded sessions */ + sessions: SessionInfo[]; + /** Sessions after filtering and sorting */ + filteredAndSortedSessions: SessionInfo[]; + + // UI state + /** Whether sessions are currently loading */ + loading: boolean; + /** Error message if loading failed */ + error: string | null; + /** Index of currently selected session */ + activeIndex: number; + /** Current scroll offset for pagination */ + scrollOffset: number; + /** Terminal width for layout calculations */ + terminalWidth: number; + + // Search state + /** Current search query string */ + searchQuery: string; + /** Whether user is in search input mode */ + isSearchMode: boolean; + /** Whether full content has been loaded for search */ + hasLoadedFullContent: boolean; + + // Sort state + /** Current sort criteria */ + sortOrder: 'date' | 'messages' | 'name'; + /** Whether sort order is reversed */ + sortReverse: boolean; + + // Computed values + /** Total number of filtered sessions */ + totalSessions: number; + /** Start index for current page */ + startIndex: number; + /** End index for current page */ + endIndex: number; + /** Sessions visible on current page */ + visibleSessions: SessionInfo[]; + + // State setters + /** Update sessions array */ + setSessions: React.Dispatch>; + /** Update loading state */ + setLoading: React.Dispatch>; + /** Update error state */ + setError: React.Dispatch>; + /** Update active session index */ + setActiveIndex: React.Dispatch>; + /** Update scroll offset */ + setScrollOffset: React.Dispatch>; + /** Update search query */ + setSearchQuery: React.Dispatch>; + /** Update search mode state */ + setIsSearchMode: React.Dispatch>; + /** Update sort order */ + setSortOrder: React.Dispatch< + React.SetStateAction<'date' | 'messages' | 'name'> + >; + /** Update sort reverse flag */ + setSortReverse: React.Dispatch>; + setHasLoadedFullContent: React.Dispatch>; +} + +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; + +const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => ( + <> + {name}: {shortcut} + +); + +/** + * Loading state component displayed while sessions are being loaded. + */ +const SessionBrowserLoading = (): React.JSX.Element => ( + + Loading sessions… + +); + +/** + * Error state component displayed when session loading fails. + */ +const SessionBrowserError = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Error: {state.error} + Press q to exit + +); + +/** + * Empty state component displayed when no sessions are found. + */ +const SessionBrowserEmpty = (): React.JSX.Element => ( + + No auto-saved conversations found. + Press q to exit + +); + +/** + * Sorts an array of sessions by the specified criteria. + * @param sessions - Array of sessions to sort + * @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName) + * @param reverse - Whether to reverse the sort order (ascending instead of descending) + * @returns New sorted array of sessions + */ +const sortSessions = ( + sessions: SessionInfo[], + sortBy: 'date' | 'messages' | 'name', + reverse: boolean, +): SessionInfo[] => { + const sorted = [...sessions].sort((a, b) => { + switch (sortBy) { + case 'date': + return ( + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime() + ); + case 'messages': + return b.messageCount - a.messageCount; + case 'name': + return a.displayName.localeCompare(b.displayName); + default: + return 0; + } + }); + + return reverse ? sorted.reverse() : sorted; +}; + +/** + * Finds all text matches for a search query within conversation messages. + * Creates TextMatch objects with context (10 chars before/after) and role information. + * @param messages - Array of messages to search through + * @param query - Search query string (case-insensitive) + * @returns Array of TextMatch objects containing match context and metadata + */ +const findTextMatches = ( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + query: string, +): TextMatch[] => { + if (!query.trim()) return []; + + const lowerQuery = query.toLowerCase(); + const matches: TextMatch[] = []; + + for (const message of messages) { + const m = cleanMessage(message.content); + const lowerContent = m.toLowerCase(); + let startIndex = 0; + + while (true) { + const matchIndex = lowerContent.indexOf(lowerQuery, startIndex); + if (matchIndex === -1) break; + + const contextStart = Math.max(0, matchIndex - 10); + const contextEnd = Math.min(m.length, matchIndex + query.length + 10); + + const snippet = m.slice(contextStart, contextEnd); + const relativeMatchStart = matchIndex - contextStart; + const relativeMatchEnd = relativeMatchStart + query.length; + + let before = snippet.slice(0, relativeMatchStart); + const match = snippet.slice(relativeMatchStart, relativeMatchEnd); + let after = snippet.slice(relativeMatchEnd); + + if (contextStart > 0) before = '…' + before; + if (contextEnd < m.length) after = after + '…'; + + matches.push({ before, match, after, role: message.role }); + startIndex = matchIndex + 1; + } + } + + return matches; +}; + +/** + * Filters sessions based on a search query, checking titles, IDs, and full content. + * Also populates matchSnippets and matchCount for sessions with content matches. + * @param sessions - Array of sessions to filter + * @param query - Search query string (case-insensitive) + * @returns Filtered array of sessions that match the query + */ +const filterSessions = ( + sessions: SessionInfo[], + query: string, +): SessionInfo[] => { + if (!query.trim()) { + return sessions.map((session) => ({ + ...session, + matchSnippets: undefined, + matchCount: undefined, + })); + } + + const lowerQuery = query.toLowerCase(); + return sessions.filter((session) => { + const titleMatch = + session.displayName.toLowerCase().includes(lowerQuery) || + session.id.toLowerCase().includes(lowerQuery) || + session.firstUserMessage.toLowerCase().includes(lowerQuery); + + const contentMatch = session.fullContent + ?.toLowerCase() + .includes(lowerQuery); + + if (titleMatch || contentMatch) { + if (session.messages) { + session.matchSnippets = findTextMatches(session.messages, query); + session.matchCount = session.matchSnippets.length; + } + return true; + } + + return false; + }); +}; + +/** + * Search input display component. + */ +const SearchModeDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Search: + {state.searchQuery} + (Esc to cancel) + +); + +/** + * Header component showing session count and sort information. + */ +const SessionListHeader = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + Chat Sessions ({state.totalSessions} total + {state.searchQuery ? `, filtered` : ''}) + + + sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'} + + +); + +/** + * Navigation help component showing keyboard shortcuts. + */ +const NavigationHelp = (): React.JSX.Element => ( + + + + {' '} + + {' '} + + {' '} + + {' '} + + + + + {' '} + + {' '} + + + +); + +/** + * 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'} + + + +); + +/** + * No results display component for empty search results. + */ +const NoResultsDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + No sessions found matching '{state.searchQuery}'. + + +); + +/** + * 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 ? Colors.AccentPurple : 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)`; + } + } + + const availableMessageWidth = Math.max( + 20, + terminalWidth - FIXED_SESSION_COLUMNS_WIDTH, + ); + + 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. + */ +export const useSessionBrowserState = ( + initialSessions: SessionInfo[] = [], + initialLoading = true, + initialError: string | null = null, +): SessionBrowserState => { + const { columns: terminalWidth } = useTerminalSize(); + const [sessions, setSessions] = useState(initialSessions); + const [loading, setLoading] = useState(initialLoading); + const [error, setError] = useState(initialError); + const [activeIndex, setActiveIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [sortOrder, setSortOrder] = useState<'date' | 'messages' | 'name'>( + 'date', + ); + const [sortReverse, setSortReverse] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchMode, setIsSearchMode] = useState(false); + const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false); + const loadingFullContentRef = useRef(false); + + const filteredAndSortedSessions = useMemo(() => { + const filtered = filterSessions(sessions, searchQuery); + return sortSessions(filtered, sortOrder, sortReverse); + }, [sessions, searchQuery, sortOrder, sortReverse]); + + // Reset full content flag when search is cleared + useEffect(() => { + if (!searchQuery) { + setHasLoadedFullContent(false); + loadingFullContentRef.current = false; + } + }, [searchQuery]); + + const totalSessions = filteredAndSortedSessions.length; + const startIndex = scrollOffset; + const endIndex = Math.min(scrollOffset + SESSIONS_PER_PAGE, totalSessions); + const visibleSessions = filteredAndSortedSessions.slice(startIndex, endIndex); + + const state: SessionBrowserState = { + sessions, + setSessions, + loading, + setLoading, + error, + setError, + activeIndex, + setActiveIndex, + scrollOffset, + setScrollOffset, + searchQuery, + setSearchQuery, + isSearchMode, + setIsSearchMode, + hasLoadedFullContent, + setHasLoadedFullContent, + sortOrder, + setSortOrder, + sortReverse, + setSortReverse, + terminalWidth, + filteredAndSortedSessions, + totalSessions, + startIndex, + endIndex, + visibleSessions, + }; + + return state; +}; + +/** + * Hook to load sessions on mount. + */ +const useLoadSessions = (config: Config, state: SessionBrowserState) => { + const { + setSessions, + setLoading, + setError, + isSearchMode, + hasLoadedFullContent, + setHasLoadedFullContent, + } = state; + + useEffect(() => { + const loadSessions = async () => { + try { + const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); + const sessionData = await getSessionFiles( + chatsDir, + config.getSessionId(), + ); + setSessions(sessionData); + setLoading(false); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to load sessions', + ); + setLoading(false); + } + }; + + loadSessions(); + }, [config, setSessions, setLoading, setError]); + + useEffect(() => { + const loadFullContent = async () => { + if (isSearchMode && !hasLoadedFullContent) { + try { + const chatsDir = path.join( + config.storage.getProjectTempDir(), + 'chats', + ); + const sessionData = await getSessionFiles( + chatsDir, + config.getSessionId(), + { includeFullContent: true }, + ); + setSessions(sessionData); + setHasLoadedFullContent(true); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to load full session content', + ); + } + } + }; + + loadFullContent(); + }, [ + isSearchMode, + hasLoadedFullContent, + config, + setSessions, + setHasLoadedFullContent, + setError, + ]); +}; + +/** + * Hook to handle selection movement. + */ +export const useMoveSelection = (state: SessionBrowserState) => { + const { + totalSessions, + activeIndex, + scrollOffset, + setActiveIndex, + setScrollOffset, + } = state; + + return useCallback( + (delta: number) => { + const newIndex = Math.max( + 0, + Math.min(totalSessions - 1, activeIndex + delta), + ); + setActiveIndex(newIndex); + + // Adjust scroll offset if needed + if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } else if (newIndex >= scrollOffset + SESSIONS_PER_PAGE) { + setScrollOffset(newIndex - SESSIONS_PER_PAGE + 1); + } + }, + [totalSessions, activeIndex, scrollOffset, setActiveIndex, setScrollOffset], + ); +}; + +/** + * Hook to handle sort order cycling. + */ +export const useCycleSortOrder = (state: SessionBrowserState) => { + const { sortOrder, setSortOrder } = state; + + return useCallback(() => { + const orders: Array<'date' | 'messages' | 'name'> = [ + 'date', + 'messages', + 'name', + ]; + const currentIndex = orders.indexOf(sortOrder); + const nextIndex = (currentIndex + 1) % orders.length; + setSortOrder(orders[nextIndex]); + }, [sortOrder, setSortOrder]); +}; + +/** + * Hook to handle SessionBrowser input. + */ +export const useSessionBrowserInput = ( + state: SessionBrowserState, + moveSelection: (delta: number) => void, + cycleSortOrder: () => void, + onResumeSession: (session: SessionInfo) => void, + onDeleteSession: ((session: SessionInfo) => void) | undefined, + onExit: () => void, +) => { + useKeypress( + (key) => { + if (state.isSearchMode) { + // Search-specific input handling. Only control/symbols here. + if (key.name === 'escape') { + state.setIsSearchMode(false); + state.setSearchQuery(''); + state.setActiveIndex(0); + state.setScrollOffset(0); + } else if (key.name === 'backspace') { + state.setSearchQuery((prev) => prev.slice(0, -1)); + state.setActiveIndex(0); + state.setScrollOffset(0); + } else if ( + key.sequence && + !key.ctrl && + !key.meta && + key.sequence.length === 1 + ) { + state.setSearchQuery((prev) => prev + key.sequence); + state.setActiveIndex(0); + state.setScrollOffset(0); + } + } else { + // Navigation mode input handling. We're keeping the letter-based controls for non-search + // mode only, because the letters need to act as input for the search. + if (key.sequence === 'g') { + state.setActiveIndex(0); + state.setScrollOffset(0); + } else if (key.sequence === 'G') { + state.setActiveIndex(state.totalSessions - 1); + state.setScrollOffset( + Math.max(0, state.totalSessions - SESSIONS_PER_PAGE), + ); + } + // Sorting controls. + else if (key.sequence === 's') { + cycleSortOrder(); + } else if (key.sequence === 'r') { + state.setSortReverse(!state.sortReverse); + } + // Searching and exit controls. + else if (key.sequence === '/') { + state.setIsSearchMode(true); + } else if ( + key.sequence === 'q' || + key.sequence === 'Q' || + key.name === 'escape' + ) { + onExit(); + } + // Delete session control. + else if (key.sequence === 'x' || key.sequence === 'X') { + const selectedSession = + state.filteredAndSortedSessions[state.activeIndex]; + if ( + selectedSession && + !selectedSession.isCurrentSession && + onDeleteSession + ) { + try { + onDeleteSession(selectedSession); + // Remove the session from the state + state.setSessions( + state.sessions.filter((s) => s.id !== selectedSession.id), + ); + + // Adjust active index if needed + if ( + state.activeIndex >= + state.filteredAndSortedSessions.length - 1 + ) { + state.setActiveIndex( + Math.max(0, state.filteredAndSortedSessions.length - 2), + ); + } + } catch (error) { + state.setError( + `Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + } + // less-like u/d controls. + else if (key.sequence === 'd') { + moveSelection(-Math.round(SESSIONS_PER_PAGE / 2)); + } else if (key.sequence === 'u') { + moveSelection(Math.round(SESSIONS_PER_PAGE / 2)); + } + } + + // Handling regardless of search mode. + if ( + key.name === 'return' && + state.filteredAndSortedSessions[state.activeIndex] + ) { + const selectedSession = + state.filteredAndSortedSessions[state.activeIndex]; + // Don't allow resuming the current session + if (!selectedSession.isCurrentSession) { + onResumeSession(selectedSession); + } + } else if (key.name === 'up') { + moveSelection(-1); + } else if (key.name === 'down') { + moveSelection(1); + } else if (key.name === 'pageup') { + moveSelection(-SESSIONS_PER_PAGE); + } else if (key.name === 'pagedown') { + moveSelection(SESSIONS_PER_PAGE); + } + }, + { isActive: true }, + ); +}; + +export function SessionBrowserView({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element { + if (state.loading) { + return ; + } + + if (state.error) { + return ; + } + + if (state.sessions.length === 0) { + return ; + } + return ( + + + + {state.isSearchMode && } + + {state.totalSessions === 0 ? ( + + ) : ( + + )} + + ); +} + +export function SessionBrowser({ + config, + onResumeSession, + onDeleteSession, + onExit, +}: SessionBrowserProps): React.JSX.Element { + // Use all our custom hooks + const state = useSessionBrowserState(); + useLoadSessions(config, state); + const moveSelection = useMoveSelection(state); + const cycleSortOrder = useCycleSortOrder(state); + useSessionBrowserInput( + state, + moveSelection, + cycleSortOrder, + onResumeSession, + onDeleteSession, + onExit, + ); + + return ; +} diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index 3face81079..80df6f3290 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -4,44 +4,165 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { convertSessionToHistoryFormats } from './useSessionBrowser.js'; -import { MessageType, ToolCallStatus } from '../types.js'; -import type { MessageRecord } from '@google/gemini-cli-core'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { act } from 'react'; +import { + useSessionBrowser, + convertSessionToHistoryFormats, +} from './useSessionBrowser.js'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js'; +import type { + Config, + ConversationRecord, + MessageRecord, +} from '@google/gemini-cli-core'; +// Mock modules +vi.mock('fs/promises'); +vi.mock('path'); +vi.mock('../../utils/sessionUtils.js'); + +const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp'; +const MOCKED_CHATS_DIR = '/test/project/temp/chats'; +const MOCKED_SESSION_ID = 'test-session-123'; +const MOCKED_CURRENT_SESSION_ID = 'current-session-id'; + +describe('useSessionBrowser', () => { + const mockedFs = vi.mocked(fs); + const mockedPath = vi.mocked(path); + const mockedGetSessionFiles = vi.mocked(getSessionFiles); + + const mockConfig = { + storage: { + getProjectTempDir: vi.fn(), + }, + setSessionId: vi.fn(), + getSessionId: vi.fn(), + getGeminiClient: vi.fn().mockReturnValue({ + getChatRecordingService: vi.fn().mockReturnValue({ + deleteSession: vi.fn(), + }), + }), + } as unknown as Config; + + const mockOnLoadHistory = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + mockedPath.join.mockImplementation((...args) => args.join('/')); + vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue( + MOCKED_PROJECT_TEMP_DIR, + ); + vi.mocked(mockConfig.getSessionId).mockReturnValue( + MOCKED_CURRENT_SESSION_ID, + ); + }); + + it('should successfully resume a session', async () => { + const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json'; + const mockConversation: ConversationRecord = { + sessionId: 'existing-session-456', + messages: [{ type: 'user', content: 'Hello' } as MessageRecord], + } as ConversationRecord; + + const mockSession = { + id: MOCKED_SESSION_ID, + fileName: MOCKED_FILENAME, + } as SessionInfo; + mockedGetSessionFiles.mockResolvedValue([mockSession]); + mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation)); + + const { result } = renderHook(() => + useSessionBrowser(mockConfig, mockOnLoadHistory), + ); + + await act(async () => { + await result.current.handleResumeSession(mockSession); + }); + expect(mockedFs.readFile).toHaveBeenCalledWith( + `${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`, + 'utf8', + ); + expect(mockConfig.setSessionId).toHaveBeenCalledWith( + 'existing-session-456', + ); + expect(result.current.isSessionBrowserOpen).toBe(false); + expect(mockOnLoadHistory).toHaveBeenCalled(); + }); + + it('should handle file read error', async () => { + const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json'; + const mockSession = { + id: MOCKED_SESSION_ID, + fileName: MOCKED_FILENAME, + } as SessionInfo; + mockedFs.readFile.mockRejectedValue(new Error('File not found')); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { result } = renderHook(() => + useSessionBrowser(mockConfig, mockOnLoadHistory), + ); + + await act(async () => { + await result.current.handleResumeSession(mockSession); + }); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(result.current.isSessionBrowserOpen).toBe(false); + consoleErrorSpy.mockRestore(); + }); + + it('should handle JSON parse error', async () => { + const MOCKED_FILENAME = 'invalid.json'; + const mockSession = { + id: MOCKED_SESSION_ID, + fileName: MOCKED_FILENAME, + } as SessionInfo; + mockedFs.readFile.mockResolvedValue('invalid json'); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { result } = renderHook(() => + useSessionBrowser(mockConfig, mockOnLoadHistory), + ); + + await act(async () => { + await result.current.handleResumeSession(mockSession); + }); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(result.current.isSessionBrowserOpen).toBe(false); + consoleErrorSpy.mockRestore(); + }); +}); + +// The convertSessionToHistoryFormats tests are self-contained and do not need changes. describe('convertSessionToHistoryFormats', () => { it('should convert empty messages array', () => { const result = convertSessionToHistoryFormats([]); - expect(result.uiHistory).toEqual([]); expect(result.clientHistory).toEqual([]); }); - it('should convert basic user and gemini messages', () => { + it('should convert basic user and model messages', () => { const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Hello', - type: 'user', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: 'Hi there!', - type: 'gemini', - }, + { type: 'user', content: 'Hello' } as MessageRecord, + { type: 'gemini', content: 'Hi there' } as MessageRecord, ]; const result = convertSessionToHistoryFormats(messages); expect(result.uiHistory).toHaveLength(2); - expect(result.uiHistory[0]).toEqual({ - type: MessageType.USER, - text: 'Hello', - }); - expect(result.uiHistory[1]).toEqual({ - type: MessageType.GEMINI, - text: 'Hi there!', + expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: 'Hello' }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'gemini', + text: 'Hi there', }); expect(result.clientHistory).toHaveLength(2); @@ -51,582 +172,92 @@ describe('convertSessionToHistoryFormats', () => { }); expect(result.clientHistory[1]).toEqual({ role: 'model', - parts: [{ text: 'Hi there!' }], + parts: [{ text: 'Hi there' }], }); }); - it('should convert system, warning, and error messages to appropriate types', () => { + it('should filter out slash commands from client history but keep in UI', () => { const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'System message', - type: 'info', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: 'Warning message', - type: 'warning', - }, - { - id: 'msg-3', - timestamp: '2025-01-01T00:03:00Z', - content: 'Error occurred', - type: 'error', - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.uiHistory[0]).toEqual({ - type: MessageType.INFO, - text: 'System message', - }); - expect(result.uiHistory[1]).toEqual({ - type: MessageType.WARNING, - text: 'Warning message', - }); - expect(result.uiHistory[2]).toEqual({ - type: MessageType.ERROR, - text: 'Error occurred', - }); - - // System, warning, and error messages should not be included in client history - expect(result.clientHistory).toEqual([]); - }); - - it('should filter out slash commands from client history', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: '/help', - type: 'user', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: '?quit', - type: 'user', - }, - { - id: 'msg-3', - timestamp: '2025-01-01T00:03:00Z', - content: 'Regular message', - type: 'user', - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // All messages should appear in UI history - expect(result.uiHistory).toHaveLength(3); - - // Only non-slash commands should appear in client history - expect(result.clientHistory).toHaveLength(1); - expect(result.clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'Regular message' }], - }); - }); - - it('should handle tool calls correctly', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: "I'll help you with that.", - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'bash', - displayName: 'Execute Command', - description: 'Run bash command', - args: { command: 'ls -la' }, - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .', - renderOutputAsMarkdown: false, - }, - { - id: 'tool-2', - name: 'read', - displayName: 'Read File', - description: 'Read file contents', - args: { path: '/etc/hosts' }, - status: 'error', - timestamp: '2025-01-01T00:01:45Z', - resultDisplay: 'Permission denied', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.uiHistory).toHaveLength(2); // text message + tool group - expect(result.uiHistory[0]).toEqual({ - type: MessageType.GEMINI, - text: "I'll help you with that.", - }); - - expect(result.uiHistory[1].type).toBe('tool_group'); - // This if-statement is only necessary because TypeScript can't tell that the toBe() assertion - // protects the .tools access below. - if (result.uiHistory[1].type === 'tool_group') { - expect(result.uiHistory[1].tools).toHaveLength(2); - expect(result.uiHistory[1].tools[0]).toEqual({ - callId: 'tool-1', - name: 'Execute Command', - description: 'Run bash command', - renderOutputAsMarkdown: false, - status: ToolCallStatus.Success, - resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .', - confirmationDetails: undefined, - }); - expect(result.uiHistory[1].tools[1]).toEqual({ - callId: 'tool-2', - name: 'Read File', - description: 'Read file contents', - renderOutputAsMarkdown: true, // default value - status: ToolCallStatus.Error, - resultDisplay: 'Permission denied', - confirmationDetails: undefined, - }); - } - }); - - it('should skip empty tool calls arrays', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Message with empty tools', - type: 'gemini', - toolCalls: [], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.uiHistory).toHaveLength(1); // Only text message - expect(result.uiHistory[0]).toEqual({ - type: MessageType.GEMINI, - text: 'Message with empty tools', - }); - }); - - it('should not add tool calls for user messages', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'User message', - type: 'user', - // This would be invalid in real usage, but testing robustness - toolCalls: [ - { - id: 'tool-1', - name: 'invalid', - args: {}, - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - ], - } as MessageRecord, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.uiHistory).toHaveLength(1); // Only user message, no tool group - expect(result.uiHistory[0]).toEqual({ - type: MessageType.USER, - text: 'User message', - }); - }); - - it('should handle missing tool call fields gracefully', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Message with minimal tool', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'minimal_tool', - args: {}, - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - // Missing optional fields - }, - ], - }, + { type: 'user', content: '/help' } as MessageRecord, + { type: 'info', content: 'Help text' } as MessageRecord, ]; const result = convertSessionToHistoryFormats(messages); expect(result.uiHistory).toHaveLength(2); - expect(result.uiHistory[1].type).toBe('tool_group'); - if (result.uiHistory[1].type === 'tool_group') { - expect(result.uiHistory[1].tools[0]).toEqual({ - callId: 'tool-1', - name: 'minimal_tool', // Falls back to name when displayName missing - description: '', // Default empty string - renderOutputAsMarkdown: true, // Default value - status: ToolCallStatus.Success, - resultDisplay: undefined, - confirmationDetails: undefined, - }); - } else { - throw new Error('unreachable'); - } + expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: '/help' }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'info', + text: 'Help text', + }); + + expect(result.clientHistory).toHaveLength(0); }); - describe('tool calls in client history', () => { - it('should convert tool calls to correct Gemini client history format', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'List files', - type: 'user', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: "I'll list the files for you.", - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'list_directory', - args: { path: '/home/user' }, - result: { - functionResponse: { - id: 'list_directory-1753650620141-f3b8b9e73919d', - name: 'list_directory', - response: { - output: 'file1.txt\nfile2.txt', - }, - }, - }, - status: 'success', - timestamp: '2025-01-01T00:02:30Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // Should have: user message, model with function call, user with function response - expect(result.clientHistory).toHaveLength(3); - - // User message - expect(result.clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'List files' }], - }); - - // Model message with function call - expect(result.clientHistory[1]).toEqual({ - role: 'model', - parts: [ - { text: "I'll list the files for you." }, + it('should handle tool calls and responses', () => { + const messages: MessageRecord[] = [ + { type: 'user', content: 'What time is it?' } as MessageRecord, + { + type: 'gemini', + content: '', + toolCalls: [ { - functionCall: { - name: 'list_directory', - args: { path: '/home/user' }, - id: 'tool-1', - }, + id: 'call_1', + name: 'get_time', + args: {}, + status: 'success', + result: '12:00', }, ], - }); + } as unknown as MessageRecord, + ]; - // Function response - expect(result.clientHistory[2]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'list_directory-1753650620141-f3b8b9e73919d', - name: 'list_directory', - response: { output: 'file1.txt\nfile2.txt' }, - }, - }, - ], - }); + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(2); + expect(result.uiHistory[0]).toMatchObject({ + type: 'user', + text: 'What time is it?', + }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'tool_group', + tools: [ + expect.objectContaining({ + callId: 'call_1', + name: 'get_time', + status: 'Success', + }), + ], }); - it('should handle tool calls without text content', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: '', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'bash', - args: { command: 'ls' }, - result: 'file1.txt\nfile2.txt', - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.clientHistory).toHaveLength(2); - - // Model message with only function call (no text) - expect(result.clientHistory[0]).toEqual({ - role: 'model', - parts: [ - { - functionCall: { - name: 'bash', - args: { command: 'ls' }, - id: 'tool-1', - }, - }, - ], - }); - - // Function response - expect(result.clientHistory[1]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'tool-1', - name: 'bash', - response: { - output: 'file1.txt\nfile2.txt', - }, - }, - }, - ], - }); + expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response) + expect(result.clientHistory[0]).toEqual({ + role: 'user', + parts: [{ text: 'What time is it?' }], }); - - it('should handle multiple tool calls in one message', () => { - const messages: MessageRecord[] = [ + expect(result.clientHistory[1]).toEqual({ + role: 'model', + parts: [ { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Running multiple commands', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'bash', - args: { command: 'pwd' }, - result: '/home/user', - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - { - id: 'tool-2', - name: 'bash', - args: { command: 'ls' }, - result: [ - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { - output: 'file1.txt', - }, - }, - }, - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { - output: 'file2.txt', - }, - }, - }, - ], - status: 'success', - timestamp: '2025-01-01T00:01:35Z', - }, - ], + functionCall: { + name: 'get_time', + args: {}, + id: 'call_1', + }, }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // Should have: model with both function calls, then one response - expect(result.clientHistory).toHaveLength(2); - - // Model message with both function calls - expect(result.clientHistory[0]).toEqual({ - role: 'model', - parts: [ - { text: 'Running multiple commands' }, - { - functionCall: { - name: 'bash', - args: { command: 'pwd' }, - id: 'tool-1', - }, - }, - { - functionCall: { - name: 'bash', - args: { command: 'ls' }, - id: 'tool-2', - }, - }, - ], - }); - - // First function response - expect(result.clientHistory[1]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'tool-1', - name: 'bash', - response: { output: '/home/user' }, - }, - }, - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { output: 'file1.txt' }, - }, - }, - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { output: 'file2.txt' }, - }, - }, - ], - }); + ], }); - - it('should handle Part array results from tools', () => { - const messages: MessageRecord[] = [ + expect(result.clientHistory[2]).toEqual({ + role: 'user', + parts: [ { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Reading file', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'read_file', - args: { path: 'test.txt' }, - result: [ - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: 'Hello', - }, - }, - }, - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: ' World', - }, - }, - }, - ], - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - ], + functionResponse: { + id: 'call_1', + name: 'get_time', + response: { output: '12:00' }, + }, }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.clientHistory).toHaveLength(2); - - // Function response should extract both function responses - expect(result.clientHistory[1]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: 'Hello', - }, - }, - }, - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: ' World', - }, - }, - }, - ], - }); - }); - - it('should skip tool calls without results', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Testing tool', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'test_tool', - args: { arg: 'value' }, - // No result field - status: 'error', - timestamp: '2025-01-01T00:01:30Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // Should only have the model message with function call, no function response - expect(result.clientHistory).toHaveLength(1); - - expect(result.clientHistory[0]).toEqual({ - role: 'model', - parts: [ - { text: 'Testing tool' }, - { - functionCall: { - name: 'test_tool', - args: { arg: 'value' }, - id: 'tool-1', - }, - }, - ], - }); + ], }); }); }); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 69a787b030..2dec70901d 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -4,11 +4,110 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useState, useCallback } from 'react'; import type { HistoryItemWithoutId } from '../types.js'; -import type { ConversationRecord } from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import type { + Config, + ConversationRecord, + ResumedSessionData, +} from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import { partListUnionToString } from '@google/gemini-cli-core'; +import type { SessionInfo } from '../../utils/sessionUtils.js'; import { MessageType, ToolCallStatus } from '../types.js'; + +export const useSessionBrowser = ( + config: Config, + onLoadHistory: ( + uiHistory: HistoryItemWithoutId[], + clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, + resumedSessionData: ResumedSessionData, + ) => void, +) => { + const [isSessionBrowserOpen, setIsSessionBrowserOpen] = useState(false); + + return { + isSessionBrowserOpen, + + openSessionBrowser: useCallback(() => { + setIsSessionBrowserOpen(true); + }, []), + + closeSessionBrowser: useCallback(() => { + setIsSessionBrowserOpen(false); + }, []), + + /** + * Loads a conversation by ID, and reinitializes the chat recording service with it. + */ + handleResumeSession: useCallback( + async (session: SessionInfo) => { + try { + const chatsDir = path.join( + config.storage.getProjectTempDir(), + 'chats', + ); + + const fileName = session.fileName; + + const originalFilePath = path.join(chatsDir, fileName); + + // Load up the conversation. + const conversation: ConversationRecord = JSON.parse( + await fs.readFile(originalFilePath, 'utf8'), + ); + + // Use the old session's ID to continue it. + const existingSessionId = conversation.sessionId; + config.setSessionId(existingSessionId); + + const resumedSessionData = { + conversation, + filePath: originalFilePath, + }; + + // We've loaded it; tell the UI about it. + setIsSessionBrowserOpen(false); + const historyData = convertSessionToHistoryFormats( + conversation.messages, + ); + onLoadHistory( + historyData.uiHistory, + historyData.clientHistory, + resumedSessionData, + ); + } catch (error) { + console.error('Error resuming session:', error); + setIsSessionBrowserOpen(false); + } + }, + [config, onLoadHistory], + ), + + /** + * Deletes a session by ID using the ChatRecordingService. + */ + handleDeleteSession: useCallback( + (session: SessionInfo) => { + try { + const chatRecordingService = config + .getGeminiClient() + ?.getChatRecordingService(); + if (chatRecordingService) { + chatRecordingService.deleteSession(session.id); + } + } catch (error) { + console.error('Error deleting session:', error); + throw error; + } + }, + [config], + ), + }; +}; + /** * Converts session/conversation data into UI history and Gemini client history formats. */ diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts index e86990e319..d47be2fc00 100644 --- a/packages/cli/src/utils/sessionCleanup.test.ts +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -52,6 +52,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 1, @@ -62,6 +64,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`, startTime: oneWeekAgo.toISOString(), lastUpdated: oneWeekAgo.toISOString(), + messageCount: 10, + displayName: 'Recent session', firstUserMessage: 'Recent session', isCurrentSession: false, index: 2, @@ -72,6 +76,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`, startTime: twoWeeksAgo.toISOString(), lastUpdated: twoWeeksAgo.toISOString(), + messageCount: 3, + displayName: 'Old session', firstUserMessage: 'Old session', isCurrentSession: false, index: 3, @@ -82,6 +88,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`, startTime: oneMonthAgo.toISOString(), lastUpdated: oneMonthAgo.toISOString(), + messageCount: 15, + displayName: 'Ancient session', firstUserMessage: 'Ancient session', isCurrentSession: false, index: 4, @@ -435,6 +443,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -445,6 +455,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}5d.json`, startTime: fiveDaysAgo.toISOString(), lastUpdated: fiveDaysAgo.toISOString(), + messageCount: 1, + displayName: '5 days old', firstUserMessage: '5 days', isCurrentSession: false, index: 2, @@ -455,6 +467,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}8d.json`, startTime: eightDaysAgo.toISOString(), lastUpdated: eightDaysAgo.toISOString(), + messageCount: 1, + displayName: '8 days old', firstUserMessage: '8 days', isCurrentSession: false, index: 3, @@ -465,6 +479,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}15d.json`, startTime: fifteenDaysAgo.toISOString(), lastUpdated: fifteenDaysAgo.toISOString(), + messageCount: 1, + displayName: '15 days old', firstUserMessage: '15 days', isCurrentSession: false, index: 4, @@ -549,6 +565,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -559,6 +577,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}1d.json`, startTime: oneDayAgo.toISOString(), lastUpdated: oneDayAgo.toISOString(), + messageCount: 1, + displayName: '1 day old', firstUserMessage: '1 day', isCurrentSession: false, index: 2, @@ -569,6 +589,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}7d.json`, startTime: sevenDaysAgo.toISOString(), lastUpdated: sevenDaysAgo.toISOString(), + messageCount: 1, + displayName: '7 days old', firstUserMessage: '7 days', isCurrentSession: false, index: 3, @@ -579,6 +601,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}13d.json`, startTime: thirteenDaysAgo.toISOString(), lastUpdated: thirteenDaysAgo.toISOString(), + messageCount: 1, + displayName: '13 days old', firstUserMessage: '13 days', isCurrentSession: false, index: 4, @@ -637,6 +661,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current (newest)', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -652,6 +678,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}${i}d.json`, startTime: daysAgo.toISOString(), lastUpdated: daysAgo.toISOString(), + messageCount: 1, + displayName: `${i} days old`, firstUserMessage: `${i} days`, isCurrentSession: false, index: i + 1, @@ -759,6 +787,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -769,6 +799,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}3d.json`, startTime: threeDaysAgo.toISOString(), lastUpdated: threeDaysAgo.toISOString(), + messageCount: 1, + displayName: '3 days old', firstUserMessage: '3 days', isCurrentSession: false, index: 2, @@ -779,6 +811,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}5d.json`, startTime: fiveDaysAgo.toISOString(), lastUpdated: fiveDaysAgo.toISOString(), + messageCount: 1, + displayName: '5 days old', firstUserMessage: '5 days', isCurrentSession: false, index: 3, @@ -789,6 +823,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}7d.json`, startTime: sevenDaysAgo.toISOString(), lastUpdated: sevenDaysAgo.toISOString(), + messageCount: 1, + displayName: '7 days old', firstUserMessage: '7 days', isCurrentSession: false, index: 4, @@ -799,6 +835,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}12d.json`, startTime: twelveDaysAgo.toISOString(), lastUpdated: twelveDaysAgo.toISOString(), + messageCount: 1, + displayName: '12 days old', firstUserMessage: '12 days', isCurrentSession: false, index: 5, diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 5c53e8bd54..51cc95e918 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -234,6 +234,70 @@ describe('SessionSelector', () => { expect(result.sessionData.messages[0].content).toBe('Latest session'); }); + it('should deduplicate sessions by ID', async () => { + const sessionId = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const sessionOriginal = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Original', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const sessionDuplicate = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T11:00:00.000Z', // Newer + messages: [ + { + type: 'user', + content: 'Newer Duplicate', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + // File 1 + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(sessionOriginal, null, 2), + ); + + // File 2 (Simulate a copy or newer version with same ID) + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(sessionDuplicate, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + const sessions = await sessionSelector.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionId); + // Should keep the one with later lastUpdated + expect(sessions[0].lastUpdated).toBe('2024-01-01T11:00:00.000Z'); + }); + it('should throw error for invalid session identifier', async () => { const sessionId1 = randomUUID(); @@ -296,7 +360,7 @@ describe('extractFirstUserMessage', () => { expect(extractFirstUserMessage(messages)).toBe('Hello world'); }); - it('should truncate long messages', () => { + it('should not truncate long messages', () => { const longMessage = 'a'.repeat(150); const messages = [ { @@ -308,8 +372,7 @@ describe('extractFirstUserMessage', () => { ] as MessageRecord[]; const result = extractFirstUserMessage(messages); - expect(result).toBe('a'.repeat(97) + '...'); - expect(result.length).toBe(100); + expect(result).toBe(longMessage); }); it('should return "Empty conversation" for no user messages', () => { diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 5cad4303bc..0995fb3d20 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -10,8 +10,8 @@ import type { MessageRecord, } from '@google/gemini-cli-core'; import { - SESSION_FILE_PREFIX, partListUnionToString, + SESSION_FILE_PREFIX, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -22,6 +22,20 @@ import path from 'node:path'; */ export const RESUME_LATEST = 'latest'; +/** + * Represents a text match found during search with surrounding context. + */ +export interface TextMatch { + /** Text content before the match (with ellipsis if truncated) */ + before: string; + /** The exact matched text */ + match: string; + /** Text content after the match (with ellipsis if truncated) */ + after: string; + /** Role of the message author where the match was found */ + role: 'user' | 'assistant'; +} + /** * Session information for display and selection purposes. */ @@ -34,14 +48,26 @@ export interface SessionInfo { fileName: string; /** ISO timestamp when session started */ startTime: string; + /** Total number of messages in the session */ + messageCount: number; /** ISO timestamp when session was last updated */ lastUpdated: string; + /** Display name for the session (typically first user message) */ + displayName: string; /** Cleaned first user message content */ firstUserMessage: string; /** Whether this is the currently active session */ isCurrentSession: boolean; /** Display index in the list */ index: number; + /** Full concatenated content (only loaded when needed for search) */ + fullContent?: string; + /** Processed messages with normalized roles (only loaded when needed) */ + messages?: Array<{ role: 'user' | 'assistant'; content: string }>; + /** Search result snippets when filtering */ + matchSnippets?: TextMatch[]; + /** Total number of matches found in this session */ + matchCount?: number; } /** @@ -60,30 +86,64 @@ export interface SessionFileEntry { export interface SessionSelectionResult { sessionPath: string; sessionData: ConversationRecord; + displayInfo: string; } +/** + * Cleans and sanitizes message content for display by: + * - Converting newlines to spaces + * - Collapsing multiple whitespace to single spaces + * - Removing non-printable characters (keeping only ASCII 32-126) + * - Trimming leading/trailing whitespace + * @param message - The raw message content to clean + * @returns Sanitized message suitable for display + */ +export const cleanMessage = (message: string): string => + message + .replace(/\n+/g, ' ') + .replace(/\s+/g, ' ') + .replace(/[^\x20-\x7E]+/g, '') // Non-printable. + .trim(); + /** * Extracts the first meaningful user message from conversation messages. */ export const extractFirstUserMessage = (messages: MessageRecord[]): string => { - const userMessage = messages.find((msg) => { - const content = partListUnionToString(msg.content); - return msg.type === 'user' && content?.trim() && content !== '/resume'; - }); + const userMessage = messages + // First try filtering out slash commands. + .filter((msg) => { + const content = partListUnionToString(msg.content); + return ( + !content.startsWith('/') && + !content.startsWith('?') && + content.trim().length > 0 + ); + }) + .find((msg) => msg.type === 'user'); + + let content: string; if (!userMessage) { - return 'Empty conversation'; + // Fallback to first user message even if it's a slash command + const firstMsg = messages.find((msg) => msg.type === 'user'); + if (!firstMsg) return 'Empty conversation'; + content = cleanMessage(partListUnionToString(firstMsg.content)); + } else { + content = cleanMessage(partListUnionToString(userMessage.content)); } - // Truncate long messages for display - const content = partListUnionToString(userMessage.content).trim(); - return content.length > 100 ? content.slice(0, 97) + '...' : content; + return content; }; /** - * Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago"). + * Formats a timestamp as relative time. + * @param timestamp - The timestamp to format + * @param style - 'long' (e.g. "2 hours ago") or 'short' (e.g. "2h") */ -export const formatRelativeTime = (timestamp: string): string => { +export const formatRelativeTime = ( + timestamp: string, + style: 'long' | 'short' = 'long', +): string => { const now = new Date(); const time = new Date(timestamp); const diffMs = now.getTime() - time.getTime(); @@ -92,17 +152,34 @@ export const formatRelativeTime = (timestamp: string): string => { const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) { - return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; - } else if (diffHours > 0) { - return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; - } else if (diffMinutes > 0) { - return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + if (style === 'short') { + if (diffSeconds < 1) return 'now'; + if (diffSeconds < 60) return `${diffSeconds}s`; + if (diffMinutes < 60) return `${diffMinutes}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 30) return `${diffDays}d`; + const diffMonths = Math.floor(diffDays / 30); + return diffMonths < 12 + ? `${diffMonths}mo` + : `${Math.floor(diffMonths / 12)}y`; } else { - return 'Just now'; + if (diffDays > 0) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffMinutes > 0) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } else { + return 'Just now'; + } } }; +export interface GetSessionOptions { + /** Whether to load full message content (needed for search) */ + includeFullContent?: boolean; +} + /** * Loads all session files (including corrupted ones) from the chats directory. * @returns Array of session file entries, with sessionInfo null for corrupted files @@ -110,6 +187,7 @@ export const formatRelativeTime = (timestamp: string): string => { export const getAllSessionFiles = async ( chatsDir: string, currentSessionId?: string, + options: GetSessionOptions = {}, ): Promise => { try { const files = await fs.readdir(chatsDir); @@ -142,15 +220,37 @@ export const getAllSessionFiles = async ( ? file.includes(currentSessionId.slice(0, 8)) : false; + let fullContent: string | undefined; + let messages: + | Array<{ role: 'user' | 'assistant'; content: string }> + | undefined; + + if (options.includeFullContent) { + fullContent = content.messages + .map((msg) => partListUnionToString(msg.content)) + .join(' '); + messages = content.messages.map((msg) => ({ + role: + msg.type === 'user' + ? ('user' as const) + : ('assistant' as const), + content: partListUnionToString(msg.content), + })); + } + const sessionInfo: SessionInfo = { id: content.sessionId, file: file.replace('.json', ''), fileName: file, startTime: content.startTime, lastUpdated: content.lastUpdated, + messageCount: content.messages.length, + displayName: firstUserMessage, firstUserMessage, isCurrentSession, index: 0, // Will be set after sorting valid sessions + fullContent, + messages, }; return { fileName: file, sessionInfo }; @@ -179,8 +279,13 @@ export const getAllSessionFiles = async ( export const getSessionFiles = async ( chatsDir: string, currentSessionId?: string, + options: GetSessionOptions = {}, ): Promise => { - const allFiles = await getAllSessionFiles(chatsDir, currentSessionId); + const allFiles = await getAllSessionFiles( + chatsDir, + currentSessionId, + options, + ); // Filter out corrupted files and extract SessionInfo const validSessions = allFiles @@ -190,17 +295,31 @@ export const getSessionFiles = async ( ) .map((entry) => entry.sessionInfo); + // Deduplicate sessions by ID + const uniqueSessionsMap = new Map(); + for (const session of validSessions) { + // If duplicate exists, keep the one with the later lastUpdated timestamp + if ( + !uniqueSessionsMap.has(session.id) || + new Date(session.lastUpdated).getTime() > + new Date(uniqueSessionsMap.get(session.id)!.lastUpdated).getTime() + ) { + uniqueSessionsMap.set(session.id, session); + } + } + const uniqueSessions = Array.from(uniqueSessionsMap.values()); + // Sort by startTime (oldest first) for stable session numbering - validSessions.sort( + uniqueSessions.sort( (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), ); // Set the correct 1-based indexes after sorting - validSessions.forEach((session, index) => { + uniqueSessions.forEach((session, index) => { session.index = index + 1; }); - return validSessions; + return uniqueSessions; }; /** @@ -318,9 +437,12 @@ export class SessionSelector { await fs.readFile(sessionPath, 'utf8'), ); + const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; + return { sessionPath, sessionData, + displayInfo, }; } catch (error) { throw new Error( diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts index 12e43682e2..6be54e463c 100644 --- a/packages/cli/src/utils/sessions.test.ts +++ b/packages/cli/src/utils/sessions.test.ts @@ -85,6 +85,8 @@ describe('listSessions', () => { fileName: 'session-2025-01-18T12-00-00-session-1.json', startTime: twoDaysAgo.toISOString(), lastUpdated: twoDaysAgo.toISOString(), + messageCount: 5, + displayName: 'First user message', firstUserMessage: 'First user message', isCurrentSession: false, index: 1, @@ -95,6 +97,8 @@ describe('listSessions', () => { fileName: 'session-2025-01-20T11-00-00-session-2.json', startTime: oneHourAgo.toISOString(), lastUpdated: oneHourAgo.toISOString(), + messageCount: 10, + displayName: 'Second user message', firstUserMessage: 'Second user message', isCurrentSession: false, index: 2, @@ -105,6 +109,8 @@ describe('listSessions', () => { fileName: 'session-2025-01-20T12-00-00-current-s.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 3, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 3, @@ -163,6 +169,8 @@ describe('listSessions', () => { fileName: 'session-2.json', startTime: session2Time.toISOString(), // Middle lastUpdated: session2Time.toISOString(), + messageCount: 5, + displayName: 'Middle session', firstUserMessage: 'Middle session', isCurrentSession: false, index: 2, @@ -173,6 +181,8 @@ describe('listSessions', () => { fileName: 'session-1.json', startTime: session1Time.toISOString(), // Oldest lastUpdated: session1Time.toISOString(), + messageCount: 5, + displayName: 'Oldest session', firstUserMessage: 'Oldest session', isCurrentSession: false, index: 1, @@ -183,6 +193,8 @@ describe('listSessions', () => { fileName: 'session-3.json', startTime: session3Time.toISOString(), // Newest lastUpdated: session3Time.toISOString(), + messageCount: 5, + displayName: 'Newest session', firstUserMessage: 'Newest session', isCurrentSession: false, index: 3, @@ -219,6 +231,8 @@ describe('listSessions', () => { fileName: 'session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test message', firstUserMessage: 'Test message', isCurrentSession: false, index: 1, @@ -252,6 +266,8 @@ describe('listSessions', () => { fileName: 'session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Only session', firstUserMessage: 'Only session', isCurrentSession: true, index: 1, @@ -348,6 +364,8 @@ describe('deleteSession', () => { fileName: 'session-file-123.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -381,6 +399,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: oneHourAgo.toISOString(), lastUpdated: oneHourAgo.toISOString(), + messageCount: 5, + displayName: 'First session', firstUserMessage: 'First session', isCurrentSession: false, index: 1, @@ -391,6 +411,8 @@ describe('deleteSession', () => { fileName: 'session-file-2.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 10, + displayName: 'Second session', firstUserMessage: 'Second session', isCurrentSession: false, index: 2, @@ -421,6 +443,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -449,6 +473,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -477,6 +503,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -505,6 +533,8 @@ describe('deleteSession', () => { fileName: 'current-session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 1, @@ -533,6 +563,8 @@ describe('deleteSession', () => { fileName: 'current-session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 1, @@ -561,6 +593,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -592,6 +626,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -626,6 +662,8 @@ describe('deleteSession', () => { fileName: 'session-file-3.json', startTime: session3Time.toISOString(), // Newest lastUpdated: session3Time.toISOString(), + messageCount: 5, + displayName: 'Newest session', firstUserMessage: 'Newest session', isCurrentSession: false, index: 3, @@ -636,6 +674,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: session1Time.toISOString(), // Oldest lastUpdated: session1Time.toISOString(), + messageCount: 5, + displayName: 'Oldest session', firstUserMessage: 'Oldest session', isCurrentSession: false, index: 1, @@ -646,6 +686,8 @@ describe('deleteSession', () => { fileName: 'session-file-2.json', startTime: session2Time.toISOString(), // Middle lastUpdated: session2Time.toISOString(), + messageCount: 5, + displayName: 'Middle session', firstUserMessage: 'Middle session', isCurrentSession: false, index: 2, diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index bb583dc72f..9207069af0 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -30,8 +30,12 @@ export async function listSessions(config: Config): Promise { .forEach((session, index) => { const current = session.isCurrentSession ? ', current' : ''; const time = formatRelativeTime(session.lastUpdated); + const title = + session.firstUserMessage.length > 100 + ? session.firstUserMessage.slice(0, 97) + '...' + : session.firstUserMessage; console.log( - ` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`, + ` ${index + 1}. ${title} (${time}${current}) [${session.id}]`, ); }); }