feat: adds a Space-to-preview affordance to the /resume session picker (#3605)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(cli): add Space-to-preview in resume session picker

Press Space on a highlighted session to open a read-only transcript
preview; Enter resumes, Esc returns. Works from both in-session
`/resume` and standalone `qwen --resume`.

The standalone path runs before `loadCliConfig`, so no real Config /
LoadedSettings exist when its render tree mounts. `StandaloneSessionPicker`
wraps the picker in stub Providers — every downstream access in the
preview render path is either optional-chained or gated on states
(Confirming / Executing) that never occur in resumed session data, so
the stubs' methods are only read, never invoked for real work. Tool
descriptions degrade to the raw function-call name in preview; users
get full fidelity after pressing Enter to resume.

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* fix(cli): guard SessionPreview separator width on narrow terminals

`'─'.repeat(boxWidth - 2)` would throw RangeError when columns < 6
(tmux splits, small panes). Clamp boxWidth to a safe minimum and
compute separatorWidth with Math.max(0, …).

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(cli): gate Space-to-preview behind enablePreview prop

`SessionPicker` is shared by the resume dialog and the delete-session
dialog. Preview's Enter shortcut forwards to `onSelect`, which for
delete is `handleDelete` — so Space → preview → Enter would silently
delete the session while the preview UI still says "Enter to resume".

Add `enablePreview?: boolean` (default false). Resume callers (the
in-app resume dialog and `--resume` standalone) opt in; the delete
dialog stays opt-out and behaves exactly as before. Footer hint and
preview render branch are both gated on the prop. Add a regression
test that emulates the delete dialog and asserts Space is a no-op,
the hint is absent, and Enter still flows straight to onSelect.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <noreply@qwen.ai>
Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
This commit is contained in:
qqqys 2026-04-25 22:41:03 +08:00 committed by GitHub
parent 70cebc46a8
commit 83d1e6dcae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 766 additions and 26 deletions

View file

@ -382,6 +382,7 @@ export const DialogManager = ({
onSelect={uiActions.handleResume}
onCancel={uiActions.closeResumeDialog}
initialSessions={uiState.resumeMatchedSessions}
enablePreview
/>
);
}

View file

@ -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 (
<SessionPreview
sessionService={sessionService}
sessionId={picker.previewSessionId}
sessionTitle={previewed?.customTitle ?? previewed?.prompt ?? undefined}
messageCount={previewed?.messageCount}
mtime={previewed?.mtime}
gitBranch={previewed?.gitBranch}
onExit={picker.exitPreview}
onResume={onSelect}
/>
);
}
return (
<Box
flexDirection="column"
@ -262,7 +296,12 @@ export function SessionPicker(props: SessionPickerProps) {
>
B
</Text>
{t(' to toggle branch')} ·
{t(' to toggle branch · ')}
</Text>
)}
{enablePreview && (
<Text color={theme.text.secondary}>
{t('Space to preview · ')}
</Text>
)}
<Text color={theme.text.secondary}>

View file

@ -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(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={vi.fn()}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
expect(lastFrame()).toContain('Loading session preview');
});
it('renders all messages after load', async () => {
const svc = mockService(fakeResumedData());
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={vi.fn()}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
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(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
messageCount={42}
mtime={Date.now() - 60_000}
gitBranch="feat/preview"
onExit={vi.fn()}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
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(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={onExit}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
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(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={vi.fn()}
onResume={onResume}
/>
</KeypressProvider>,
);
await wait(100);
stdin.write('\r'); // Enter
await wait(50);
expect(onResume).toHaveBeenCalledWith('s1');
});
});

View file

@ -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<ResumedSessionData | null>(null);
const [error, setError] = useState<string | null>(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 (
<Box flexDirection="column" width={boxWidth}>
{/* Header */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{sessionTitle ?? t('Session Preview')}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>{'─'.repeat(separatorWidth)}</Text>
</Box>
{/* Body: render all items, let the terminal's scrollback own overflow. */}
{error ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.status.error}>{error}</Text>
</Box>
) : !data ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{t('Loading session preview...')}
</Text>
</Box>
) : (
<Box flexDirection="column">
{items.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
terminalWidth={boxWidth}
isPending={false}
/>
))}
</Box>
)}
{/* Footer */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(separatorWidth)}</Text>
</Box>
{metaLine && (
<Box paddingX={1}>
<Text color={theme.text.secondary}>{metaLine}</Text>
</Box>
)}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{t('Enter to resume · Esc to back')}
</Text>
</Box>
</Box>
);
}

View file

@ -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(
<KeypressProvider kittyProtocolEnabled={false}>
<ConfigContext.Provider value={PREVIEW_CONFIG_STUB}>
<SettingsContext.Provider value={PREVIEW_SETTINGS_STUB}>
{children}
</SettingsContext.Provider>
</ConfigContext.Provider>
</KeypressProvider>,
);
}
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(
<SessionPicker
sessionService={service as never}
onSelect={vi.fn()}
onCancel={vi.fn()}
enablePreview
/>,
);
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(
<SessionPicker
sessionService={service as never}
onSelect={vi.fn()}
onCancel={vi.fn()}
enablePreview
/>,
);
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(
<SessionPicker
sessionService={service as never}
onSelect={onSelect}
onCancel={vi.fn()}
enablePreview
/>,
);
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(
<SessionPicker
sessionService={service as never}
onSelect={onSelect}
onCancel={vi.fn()}
// intentionally NO enablePreview — emulates the delete dialog
/>,
);
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();
});
});
});

View file

@ -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 (
<SessionPicker
sessionService={sessionService}
onSelect={(id) => {
onSelect(id);
handleExit();
}}
onCancel={() => {
onCancel();
handleExit();
}}
currentBranch={currentBranch}
centerSelection={true}
initialSessions={initialSessions}
/>
<ConfigContext.Provider value={PREVIEW_CONFIG_STUB}>
<SettingsContext.Provider value={PREVIEW_SETTINGS_STUB}>
<SessionPicker
sessionService={sessionService}
onSelect={(id) => {
onSelect(id);
handleExit();
}}
onCancel={() => {
onCancel();
handleExit();
}}
currentBranch={currentBranch}
centerSelection={true}
initialSessions={initialSessions}
enablePreview
/>
</SettingsContext.Provider>
</ConfigContext.Provider>
);
}

View file

@ -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<void>;
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<string | null>(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,
};
}

View file

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