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[] = [];