mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
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
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:
parent
70cebc46a8
commit
83d1e6dcae
8 changed files with 766 additions and 26 deletions
|
|
@ -382,6 +382,7 @@ export const DialogManager = ({
|
|||
onSelect={uiActions.handleResume}
|
||||
onCancel={uiActions.closeResumeDialog}
|
||||
initialSessions={uiState.resumeMatchedSessions}
|
||||
enablePreview
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
176
packages/cli/src/ui/components/SessionPreview.test.tsx
Normal file
176
packages/cli/src/ui/components/SessionPreview.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
164
packages/cli/src/ui/components/SessionPreview.tsx
Normal file
164
packages/cli/src/ui/components/SessionPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue