diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index dc35c33d1..e626a60df 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -382,6 +382,7 @@ export const DialogManager = ({
onSelect={uiActions.handleResume}
onCancel={uiActions.closeResumeDialog}
initialSessions={uiState.resumeMatchedSessions}
+ enablePreview
/>
);
}
diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx
index c80c35be1..8bcf3f5b9 100644
--- a/packages/cli/src/ui/components/SessionPicker.tsx
+++ b/packages/cli/src/ui/components/SessionPicker.tsx
@@ -18,6 +18,7 @@ import {
} from '../utils/sessionPickerUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { t } from '../../i18n/index.js';
+import { SessionPreview } from './SessionPreview.js';
export interface SessionPickerProps {
sessionService: SessionService | null;
@@ -41,6 +42,14 @@ export interface SessionPickerProps {
* When provided, skips initial load and disables pagination.
*/
initialSessions?: SessionData[];
+
+ /**
+ * Enable Space-to-preview. Off by default — preview's Enter shortcut
+ * forwards to `onSelect`, which for resume flows is "resume", but for
+ * destructive flows (e.g. delete) would commit the action. Only opt in
+ * for non-destructive selection flows.
+ */
+ enablePreview?: boolean;
}
const PREFIX_CHARS = {
@@ -147,6 +156,7 @@ export function SessionPicker(props: SessionPickerProps) {
title,
centerSelection = true,
initialSessions,
+ enablePreview = false,
} = props;
const { columns: width, rows: height } = useTerminalSize();
@@ -172,8 +182,32 @@ export function SessionPicker(props: SessionPickerProps) {
centerSelection,
initialSessions,
isActive: true,
+ enablePreview,
});
+ if (
+ enablePreview &&
+ picker.viewMode === 'preview' &&
+ picker.previewSessionId &&
+ sessionService
+ ) {
+ const previewed = picker.filteredSessions.find(
+ (s) => s.sessionId === picker.previewSessionId,
+ );
+ return (
+
+ );
+ }
+
return (
B
- {t(' to toggle branch')} ·
+ {t(' to toggle branch · ')}
+
+ )}
+ {enablePreview && (
+
+ {t('Space to preview · ')}
)}
diff --git a/packages/cli/src/ui/components/SessionPreview.test.tsx b/packages/cli/src/ui/components/SessionPreview.test.tsx
new file mode 100644
index 000000000..479eeee54
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionPreview.test.tsx
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Code
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { render } from 'ink-testing-library';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { KeypressProvider } from '../contexts/KeypressContext.js';
+import { SessionPreview } from './SessionPreview.js';
+
+beforeEach(() => {
+ Object.defineProperty(process.stdout, 'columns', {
+ value: 80,
+ configurable: true,
+ });
+ Object.defineProperty(process.stdout, 'rows', {
+ value: 24,
+ configurable: true,
+ });
+});
+
+afterEach(() => vi.clearAllMocks());
+
+const wait = (ms = 50) => new Promise((r) => setTimeout(r, ms));
+
+function mockService(resolved: unknown) {
+ return {
+ loadSession: vi
+ .fn()
+ .mockReturnValue(
+ resolved instanceof Promise ? resolved : Promise.resolve(resolved),
+ ),
+ listSessions: vi.fn(),
+ loadLastSession: vi.fn(),
+ } as never;
+}
+
+function fakeResumedData() {
+ return {
+ conversation: {
+ sessionId: 's1',
+ projectHash: 'h',
+ startTime: '2026-01-01T00:00:00.000Z',
+ lastUpdated: '2026-01-01T00:00:00.000Z',
+ messages: [
+ {
+ uuid: 'u1',
+ parentUuid: null,
+ sessionId: 's1',
+ timestamp: '2026-01-01T00:00:00.000Z',
+ type: 'user',
+ cwd: '/tmp',
+ version: 'test',
+ message: {
+ role: 'user',
+ parts: [{ text: 'Hello world PREVIEW-MARKER' }],
+ },
+ },
+ {
+ uuid: 'u2',
+ parentUuid: 'u1',
+ sessionId: 's1',
+ timestamp: '2026-01-01T00:00:01.000Z',
+ type: 'assistant',
+ cwd: '/tmp',
+ version: 'test',
+ message: {
+ role: 'model',
+ parts: [{ text: 'Hi from assistant REPLY-MARKER' }],
+ },
+ },
+ ],
+ },
+ filePath: '/tmp/s1.jsonl',
+ lastCompletedUuid: 'u2',
+ };
+}
+
+describe('SessionPreview', () => {
+ it('shows loading state before data arrives', () => {
+ const svc = mockService(new Promise(() => {})); // never resolves
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ expect(lastFrame()).toContain('Loading session preview');
+ });
+
+ it('renders all messages after load', async () => {
+ const svc = mockService(fakeResumedData());
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ await wait(100);
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('PREVIEW-MARKER');
+ expect(frame).toContain('REPLY-MARKER');
+ });
+
+ it('renders footer metadata (messageCount · time · branch)', async () => {
+ const svc = mockService(fakeResumedData());
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ await wait(100);
+ const frame = lastFrame() ?? '';
+ expect(frame).toMatch(/42\s*messages/);
+ expect(frame).toContain('feat/preview');
+ });
+
+ it('calls onExit when Escape is pressed', async () => {
+ const onExit = vi.fn();
+ const svc = mockService(fakeResumedData());
+ const { stdin } = render(
+
+
+ ,
+ );
+ await wait(100);
+ stdin.write('\u001B'); // ESC
+ await wait(50);
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onResume(sessionId) when Enter is pressed', async () => {
+ const onResume = vi.fn();
+ const svc = mockService(fakeResumedData());
+ const { stdin } = render(
+
+
+ ,
+ );
+ await wait(100);
+ stdin.write('\r'); // Enter
+ await wait(50);
+ expect(onResume).toHaveBeenCalledWith('s1');
+ });
+});
diff --git a/packages/cli/src/ui/components/SessionPreview.tsx b/packages/cli/src/ui/components/SessionPreview.tsx
new file mode 100644
index 000000000..c8b9cb4dd
--- /dev/null
+++ b/packages/cli/src/ui/components/SessionPreview.tsx
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Code
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import { useEffect, useMemo, useState } from 'react';
+import type {
+ ResumedSessionData,
+ SessionService,
+} from '@qwen-code/qwen-code-core';
+import { theme } from '../semantic-colors.js';
+import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { useTerminalSize } from '../hooks/useTerminalSize.js';
+import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
+import { formatRelativeTime } from '../utils/formatters.js';
+import { formatMessageCount } from '../utils/sessionPickerUtils.js';
+import { t } from '../../i18n/index.js';
+
+export interface SessionPreviewProps {
+ sessionService: SessionService;
+ sessionId: string;
+ sessionTitle?: string;
+ /** Message count from the session list entry, for the footer. */
+ messageCount?: number;
+ /** Last-modified time (ms epoch) from the session list entry, for the footer. */
+ mtime?: number;
+ /** Git branch from the session list entry, for the footer. */
+ gitBranch?: string;
+ onExit: () => void;
+ onResume: (sessionId: string) => void;
+}
+
+export function SessionPreview(props: SessionPreviewProps) {
+ const {
+ sessionService,
+ sessionId,
+ sessionTitle,
+ messageCount,
+ mtime,
+ gitBranch,
+ onExit,
+ onResume,
+ } = props;
+ const { columns } = useTerminalSize();
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setData(null);
+ setError(null);
+ sessionService
+ .loadSession(sessionId)
+ .then((d: ResumedSessionData | undefined) => {
+ if (cancelled) return;
+ if (!d) {
+ setError('Session not found');
+ return;
+ }
+ setData(d);
+ })
+ .catch((e: unknown) => {
+ if (cancelled) return;
+ setError(e instanceof Error ? e.message : String(e));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [sessionService, sessionId]);
+
+ // Preview passes `null` config: tool_group entries degrade to name-only
+ // (no description). Users can press Enter to resume for full fidelity.
+ const items = useMemo(() => {
+ if (!data) return [];
+ return buildResumedHistoryItems(data, null);
+ }, [data]);
+
+ useKeypress(
+ (key) => {
+ const { name, ctrl } = key;
+ if (name === 'escape' || (ctrl && name === 'c')) {
+ onExit();
+ return;
+ }
+ if (name === 'return') {
+ onResume(sessionId);
+ }
+ },
+ { isActive: true },
+ );
+
+ // Clamp to a safe minimum: `'─'.repeat(boxWidth - 2)` would throw RangeError
+ // in very narrow terminals (tmux splits, small panes) if boxWidth < 2.
+ const boxWidth = Math.max(10, columns - 4);
+ const separatorWidth = Math.max(0, boxWidth - 2);
+
+ const metaParts: string[] = [];
+ if (typeof messageCount === 'number') {
+ metaParts.push(formatMessageCount(messageCount));
+ }
+ if (typeof mtime === 'number') {
+ metaParts.push(formatRelativeTime(mtime));
+ }
+ if (gitBranch) {
+ metaParts.push(gitBranch);
+ }
+ const metaLine = metaParts.join(' · ');
+
+ return (
+
+ {/* Header */}
+
+
+ {sessionTitle ?? t('Session Preview')}
+
+
+
+ {'─'.repeat(separatorWidth)}
+
+
+ {/* Body: render all items, let the terminal's scrollback own overflow. */}
+ {error ? (
+
+ {error}
+
+ ) : !data ? (
+
+
+ {t('Loading session preview...')}
+
+
+ ) : (
+
+ {items.map((item) => (
+
+ ))}
+
+ )}
+
+ {/* Footer */}
+
+ {'─'.repeat(separatorWidth)}
+
+ {metaLine && (
+
+ {metaLine}
+
+ )}
+
+
+ {t('Enter to resume · Esc to back')}
+
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
index 9a7c7b193..629231eb9 100644
--- a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
+++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
@@ -4,11 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import type { ReactNode } from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { KeypressProvider } from '../contexts/KeypressContext.js';
+import { ConfigContext } from '../contexts/ConfigContext.js';
+import { SettingsContext } from '../contexts/SettingsContext.js';
import { SessionPicker } from './SessionPicker.js';
+import type { LoadedSettings } from '../../config/settings.js';
import type {
+ Config,
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
@@ -621,4 +626,281 @@ describe('SessionPicker', () => {
unmount();
});
});
+
+ describe('Preview Mode', () => {
+ // Mirror `StandaloneSessionPicker`'s runtime wrapping so the preview
+ // render tree (ToolGroupMessage, ToolMessage) can safely call
+ // `useConfig()` / `useSettings()` in tests. Without these, any test
+ // whose previewed session contains tool calls would crash.
+ const PREVIEW_CONFIG_STUB = {
+ getShouldUseNodePtyShell: () => false,
+ getIdeMode: () => false,
+ isTrustedFolder: () => false,
+ getToolRegistry: () => ({ getTool: () => undefined }),
+ getContentGenerator: () => ({ useSummarizedThinking: () => false }),
+ } as unknown as Config;
+ const PREVIEW_SETTINGS_STUB = {
+ merged: { ui: {} },
+ } as unknown as LoadedSettings;
+
+ function renderPicker(children: ReactNode) {
+ return render(
+
+
+
+ {children}
+
+
+ ,
+ );
+ }
+
+ function fakeResumedData(sessionId: string) {
+ return {
+ conversation: {
+ sessionId,
+ projectHash: 'h',
+ startTime: '2026-01-01T00:00:00.000Z',
+ lastUpdated: '2026-01-01T00:00:00.000Z',
+ messages: [
+ {
+ uuid: 'u1',
+ parentUuid: null,
+ sessionId,
+ timestamp: '2026-01-01T00:00:00.000Z',
+ type: 'user',
+ cwd: '/tmp',
+ version: 'test',
+ message: {
+ role: 'user',
+ parts: [{ text: 'USER-ASKED-THIS' }],
+ },
+ },
+ {
+ uuid: 'u2',
+ parentUuid: 'u1',
+ sessionId,
+ timestamp: '2026-01-01T00:00:01.000Z',
+ type: 'assistant',
+ cwd: '/tmp',
+ version: 'test',
+ message: {
+ role: 'model',
+ parts: [{ text: 'ASSISTANT-REPLIED' }],
+ },
+ },
+ ],
+ },
+ filePath: `/tmp/${sessionId}.jsonl`,
+ lastCompletedUuid: 'u2',
+ };
+ }
+
+ it('opens preview on Space and closes on Esc', async () => {
+ const sessions = [
+ createMockSession({
+ sessionId: 's1',
+ prompt: 'First session',
+ messageCount: 2,
+ }),
+ ];
+ const service = createMockSessionService(sessions);
+ service.loadSession.mockResolvedValue(fakeResumedData('s1'));
+
+ const { stdin, lastFrame } = renderPicker(
+ ,
+ );
+
+ await wait(100);
+ expect(lastFrame()).toContain('First session');
+
+ stdin.write(' '); // Space
+ await wait(150);
+ const previewFrame = lastFrame() ?? '';
+ expect(previewFrame).toContain('USER-ASKED-THIS');
+ expect(previewFrame).toContain('ASSISTANT-REPLIED');
+
+ stdin.write('\u001B'); // Esc
+ await wait(50);
+ const afterExitFrame = lastFrame() ?? '';
+ expect(afterExitFrame).toContain('First session');
+ expect(afterExitFrame).not.toContain('USER-ASKED-THIS');
+ });
+
+ it('renders tool_group items without crashing (stub Providers mounted)', async () => {
+ // The previewed session contains a function call + tool_result, which
+ // produces a `tool_group` HistoryItem that exercises ToolGroupMessage
+ // and ToolMessage — the places that throw without stub Providers.
+ const toolSession = {
+ conversation: {
+ sessionId: 's1',
+ projectHash: 'h',
+ startTime: '2026-01-01T00:00:00.000Z',
+ lastUpdated: '2026-01-01T00:00:00.000Z',
+ messages: [
+ {
+ uuid: 'u1',
+ parentUuid: null,
+ sessionId: 's1',
+ timestamp: '2026-01-01T00:00:00.000Z',
+ type: 'user',
+ cwd: '/tmp',
+ version: 'test',
+ message: { role: 'user', parts: [{ text: 'list files' }] },
+ },
+ {
+ uuid: 'u2',
+ parentUuid: 'u1',
+ sessionId: 's1',
+ timestamp: '2026-01-01T00:00:01.000Z',
+ type: 'assistant',
+ cwd: '/tmp',
+ version: 'test',
+ message: {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'call-1',
+ name: 'BashTool',
+ args: { command: 'ls' },
+ },
+ },
+ ],
+ },
+ },
+ {
+ uuid: 'u3',
+ parentUuid: 'u2',
+ sessionId: 's1',
+ timestamp: '2026-01-01T00:00:02.000Z',
+ type: 'tool_result',
+ cwd: '/tmp',
+ version: 'test',
+ toolCallResult: {
+ callId: 'call-1',
+ resultDisplay: 'a.txt\nb.txt',
+ status: 'success',
+ },
+ },
+ ],
+ },
+ filePath: '/tmp/s1.jsonl',
+ lastCompletedUuid: 'u3',
+ };
+
+ const sessions = [
+ createMockSession({
+ sessionId: 's1',
+ prompt: 'list files',
+ messageCount: 3,
+ }),
+ ];
+ const service = createMockSessionService(sessions);
+ service.loadSession.mockResolvedValue(toolSession);
+
+ const { stdin, lastFrame } = renderPicker(
+ ,
+ );
+
+ await wait(100);
+ stdin.write(' '); // Space → preview
+ await wait(150);
+ const frame = lastFrame() ?? '';
+ // Tool group renders with raw function name fallback (no registry).
+ expect(frame).toContain('BashTool');
+ });
+
+ it('Enter inside preview fires onSelect with previewed sessionId', async () => {
+ const sessions = [
+ createMockSession({
+ sessionId: 's1',
+ prompt: 'First',
+ messageCount: 2,
+ }),
+ createMockSession({
+ sessionId: 's2',
+ prompt: 'Second',
+ messageCount: 2,
+ }),
+ ];
+ const service = createMockSessionService(sessions);
+ service.loadSession.mockResolvedValue(fakeResumedData('s1'));
+ const onSelect = vi.fn();
+
+ const { stdin } = renderPicker(
+ ,
+ );
+
+ await wait(100);
+ stdin.write(' '); // open preview on s1
+ await wait(150);
+ stdin.write('\r'); // Enter
+ await wait(50);
+ expect(onSelect).toHaveBeenCalledWith('s1');
+ });
+
+ it('without enablePreview, Space is a no-op and footer omits the hint', async () => {
+ // Regression: SessionPicker is also reused by the delete-session
+ // dialog, where `onSelect = handleDelete`. If preview were on by
+ // default, Space → preview → Enter would silently delete the session
+ // while the preview UI still says "Enter to resume". The default must
+ // stay opt-in.
+ const sessions = [
+ createMockSession({
+ sessionId: 's1',
+ prompt: 'Deletable session',
+ messageCount: 2,
+ }),
+ ];
+ const service = createMockSessionService(sessions);
+ service.loadSession.mockResolvedValue(fakeResumedData('s1'));
+ const onSelect = vi.fn();
+
+ const { stdin, lastFrame } = renderPicker(
+ ,
+ );
+
+ await wait(100);
+ const beforeFrame = lastFrame() ?? '';
+ expect(beforeFrame).toContain('Deletable session');
+ // Hint must not appear, otherwise we are training users to press
+ // Space in destructive flows.
+ expect(beforeFrame).not.toContain('Space to preview');
+
+ stdin.write(' '); // Space
+ await wait(150);
+ const afterFrame = lastFrame() ?? '';
+ // No preview body, still on the list.
+ expect(afterFrame).not.toContain('USER-ASKED-THIS');
+ expect(afterFrame).toContain('Deletable session');
+
+ // Enter must still call onSelect on the highlighted row (delete path
+ // unchanged), not be eaten by a phantom preview.
+ stdin.write('\r');
+ await wait(50);
+ expect(onSelect).toHaveBeenCalledWith('s1');
+ expect(service.loadSession).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx
index ba9a3f21f..1a245e0ab 100644
--- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx
+++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx
@@ -9,12 +9,41 @@ import { render, Box, useApp } from 'ink';
import {
getGitBranch,
SessionService,
+ type Config,
type SessionListItem,
} from '@qwen-code/qwen-code-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
+import { ConfigContext } from '../contexts/ConfigContext.js';
+import { SettingsContext } from '../contexts/SettingsContext.js';
+import type { LoadedSettings } from '../../config/settings.js';
import { SessionPicker } from './SessionPicker.js';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
+/**
+ * `--resume` runs this picker BEFORE `loadCliConfig`, so no real Config /
+ * LoadedSettings exist yet. But the preview render tree (HistoryItemDisplay
+ * → ToolGroupMessage → ToolMessage) calls `useConfig()` / `useSettings()`,
+ * which throw without a Provider mounted.
+ *
+ * These stubs satisfy the Context consumers. Every downstream access of
+ * Config/Settings in the preview path is either optional-chained or gated
+ * on states (Confirming / Executing) that never occur in resumed session
+ * data, so the stubbed methods are only read, never invoked for real work.
+ * Tool descriptions fall back to the raw function-call name (see
+ * `buildResumedHistoryItems` handling when the registry returns undefined).
+ */
+const PREVIEW_CONFIG_STUB = {
+ getShouldUseNodePtyShell: () => false,
+ getIdeMode: () => false,
+ isTrustedFolder: () => false,
+ getToolRegistry: () => ({ getTool: () => undefined }),
+ getContentGenerator: () => ({ useSummarizedThinking: () => false }),
+} as unknown as Config;
+
+const PREVIEW_SETTINGS_STUB = {
+ merged: { ui: {} },
+} as unknown as LoadedSettings;
+
interface StandalonePickerScreenProps {
sessionService: SessionService;
onSelect: (sessionId: string) => void;
@@ -43,20 +72,25 @@ function StandalonePickerScreen({
}
return (
- {
- onSelect(id);
- handleExit();
- }}
- onCancel={() => {
- onCancel();
- handleExit();
- }}
- currentBranch={currentBranch}
- centerSelection={true}
- initialSessions={initialSessions}
- />
+
+
+ {
+ onSelect(id);
+ handleExit();
+ }}
+ onCancel={() => {
+ onCancel();
+ handleExit();
+ }}
+ currentBranch={currentBranch}
+ centerSelection={true}
+ initialSessions={initialSessions}
+ enablePreview
+ />
+
+
);
}
diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts
index 98bba2f65..fef9dbd07 100644
--- a/packages/cli/src/ui/hooks/useSessionPicker.ts
+++ b/packages/cli/src/ui/hooks/useSessionPicker.ts
@@ -48,6 +48,11 @@ export interface UseSessionPickerOptions {
* Enable/disable input handling.
*/
isActive?: boolean;
+ /**
+ * Enable Space-to-preview. See SessionPickerProps.enablePreview for the
+ * safety rationale (preview's Enter forwards to onSelect).
+ */
+ enablePreview?: boolean;
}
export interface UseSessionPickerResult {
@@ -61,6 +66,9 @@ export interface UseSessionPickerResult {
showScrollUp: boolean;
showScrollDown: boolean;
loadMoreSessions: () => Promise;
+ viewMode: 'list' | 'preview';
+ previewSessionId: string | null;
+ exitPreview: () => void;
}
export function useSessionPicker({
@@ -72,6 +80,7 @@ export function useSessionPicker({
centerSelection = false,
initialSessions,
isActive = true,
+ enablePreview = false,
}: UseSessionPickerOptions): UseSessionPickerResult {
const hasInitialSessions = initialSessions !== undefined;
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -86,6 +95,15 @@ export function useSessionPicker({
// For follow mode (non-centered)
const [followScrollOffset, setFollowScrollOffset] = useState(0);
+ // Preview view state.
+ const [viewMode, setViewMode] = useState<'list' | 'preview'>('list');
+ const [previewSessionId, setPreviewSessionId] = useState(null);
+
+ const exitPreview = useCallback(() => {
+ setViewMode('list');
+ setPreviewSessionId(null);
+ }, []);
+
const isLoadingMoreRef = useRef(false);
const filteredSessions = useMemo(
@@ -213,6 +231,12 @@ export function useSessionPicker({
// Key handling (KeypressContext)
useKeypress(
(key) => {
+ // Preview owns the keyboard while active; suppress list-mode
+ // handlers so we don't double-handle Escape / Enter / navigation.
+ if (viewMode !== 'list') {
+ return;
+ }
+
const { name, sequence, ctrl } = key;
if (name === 'escape' || (ctrl && name === 'c')) {
@@ -264,13 +288,22 @@ export function useSessionPicker({
return;
}
+ if (name === 'space' && enablePreview) {
+ const session = filteredSessions[selectedIndex];
+ if (session) {
+ setPreviewSessionId(session.sessionId);
+ setViewMode('preview');
+ }
+ return;
+ }
+
if (sequence === 'b' || sequence === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
}
},
- { isActive },
+ { isActive: isActive && viewMode === 'list' },
);
return {
@@ -284,5 +317,8 @@ export function useSessionPicker({
showScrollUp,
showScrollDown,
loadMoreSessions,
+ viewMode,
+ previewSessionId,
+ exitPreview,
};
}
diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts
index 1807cfd85..2de73a5b1 100644
--- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts
+++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts
@@ -82,7 +82,11 @@ function extractFunctionCalls(
return calls;
}
-function getTool(config: Config, name: string): AnyDeclarativeTool | undefined {
+function getTool(
+ config: Config | null,
+ name: string,
+): AnyDeclarativeTool | undefined {
+ if (!config) return undefined;
const toolRegistry = config.getToolRegistry();
return toolRegistry.getTool(name);
}
@@ -144,7 +148,7 @@ function restoreHistoryItem(raw: unknown): HistoryItemWithoutId | undefined {
*/
function convertToHistoryItems(
conversation: ConversationRecord,
- config: Config,
+ config: Config | null,
): HistoryItemWithoutId[] {
const items: HistoryItemWithoutId[] = [];
const pendingAtCommands: AtCommandRecordPayload[] = [];
@@ -321,12 +325,13 @@ function convertToHistoryItems(
case 'assistant': {
const parts = record.message?.parts as Part[] | undefined;
- // Extract thought content
- const thoughtText = !config
- .getContentGenerator()
- .useSummarizedThinking()
- ? extractThoughtTextFromParts(parts)
- : '';
+ // Extract thought content. With no config (standalone picker preview),
+ // default to showing thoughts verbatim (same path as
+ // `!useSummarizedThinking()`).
+ const thoughtText =
+ !config || !config.getContentGenerator().useSummarizedThinking()
+ ? extractThoughtTextFromParts(parts)
+ : '';
// Extract text content (non-function-call, non-thought)
const text = extractTextFromParts(parts);
@@ -451,13 +456,16 @@ function convertToHistoryItems(
* and assigns unique IDs to each item for use with loadHistory.
*
* @param sessionData The resumed session data from SessionService
- * @param config The config object for accessing tool registry
+ * @param config The config object for accessing tool registry. Pass `null`
+ * to render in "preview" mode (no tool metadata lookup, thoughts shown
+ * verbatim) — used by the standalone resume picker that runs before
+ * `loadCliConfig`.
* @param baseTimestamp Base timestamp for generating unique IDs
* @returns Array of HistoryItem with proper IDs
*/
export function buildResumedHistoryItems(
sessionData: ResumedSessionData,
- config: Config,
+ config: Config | null,
baseTimestamp: number = Date.now(),
): HistoryItem[] {
const items: HistoryItem[] = [];