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
+"
+`;