mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
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>
906 lines
26 KiB
TypeScript
906 lines
26 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Code
|
|
* 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';
|
|
|
|
vi.mock('@qwen-code/qwen-code-core', async () => {
|
|
const actual = await vi.importActual('@qwen-code/qwen-code-core');
|
|
return {
|
|
...actual,
|
|
getGitBranch: vi.fn().mockReturnValue('main'),
|
|
};
|
|
});
|
|
|
|
// Mock terminal size
|
|
const mockTerminalSize = { columns: 80, rows: 24 };
|
|
|
|
beforeEach(() => {
|
|
Object.defineProperty(process.stdout, 'columns', {
|
|
value: mockTerminalSize.columns,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(process.stdout, 'rows', {
|
|
value: mockTerminalSize.rows,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
// Helper to create mock sessions
|
|
function createMockSession(
|
|
overrides: Partial<SessionListItem> = {},
|
|
): SessionListItem {
|
|
return {
|
|
sessionId: 'test-session-id',
|
|
cwd: '/test/path',
|
|
startTime: '2025-01-01T00:00:00.000Z',
|
|
mtime: Date.now(),
|
|
prompt: 'Test prompt',
|
|
gitBranch: 'main',
|
|
filePath: '/test/path/sessions/test-session-id.jsonl',
|
|
messageCount: 5,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Helper to create mock session service
|
|
function createMockSessionService(
|
|
sessions: SessionListItem[] = [],
|
|
hasMore = false,
|
|
) {
|
|
return {
|
|
listSessions: vi.fn().mockResolvedValue({
|
|
items: sessions,
|
|
hasMore,
|
|
nextCursor: hasMore ? Date.now() : undefined,
|
|
} as ListSessionsResult),
|
|
loadSession: vi.fn(),
|
|
loadLastSession: vi
|
|
.fn()
|
|
.mockResolvedValue(sessions.length > 0 ? {} : undefined),
|
|
};
|
|
}
|
|
|
|
describe('SessionPicker', () => {
|
|
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Empty Sessions', () => {
|
|
it('should show sessions with 0 messages', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 'empty-1',
|
|
messageCount: 0,
|
|
prompt: '',
|
|
}),
|
|
createMockSession({
|
|
sessionId: 'with-messages',
|
|
messageCount: 5,
|
|
prompt: 'Hello',
|
|
}),
|
|
createMockSession({
|
|
sessionId: 'empty-2',
|
|
messageCount: 0,
|
|
prompt: '(empty prompt)',
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('Hello');
|
|
// Should show empty sessions too (rendered as "(empty prompt)" + "0 messages")
|
|
expect(output).toContain('0 messages');
|
|
});
|
|
|
|
it('should show sessions even when all sessions are empty', async () => {
|
|
const sessions = [
|
|
createMockSession({ sessionId: 'empty-1', messageCount: 0 }),
|
|
createMockSession({ sessionId: 'empty-2', messageCount: 0 }),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('0 messages');
|
|
});
|
|
|
|
it('should show sessions with 1 or more messages', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 'one-msg',
|
|
messageCount: 1,
|
|
prompt: 'Single message',
|
|
}),
|
|
createMockSession({
|
|
sessionId: 'many-msg',
|
|
messageCount: 10,
|
|
prompt: 'Many messages',
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('Single message');
|
|
expect(output).toContain('Many messages');
|
|
expect(output).toContain('1 message');
|
|
expect(output).toContain('10 messages');
|
|
});
|
|
});
|
|
|
|
describe('Branch Filtering', () => {
|
|
it('should filter by branch when B is pressed', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 's1',
|
|
gitBranch: 'main',
|
|
prompt: 'Main branch',
|
|
messageCount: 1,
|
|
}),
|
|
createMockSession({
|
|
sessionId: 's2',
|
|
gitBranch: 'feature',
|
|
prompt: 'Feature branch',
|
|
messageCount: 1,
|
|
}),
|
|
createMockSession({
|
|
sessionId: 's3',
|
|
gitBranch: 'main',
|
|
prompt: 'Also main',
|
|
messageCount: 1,
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame, stdin } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
currentBranch="main"
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
// All sessions should be visible initially
|
|
let output = lastFrame();
|
|
expect(output).toContain('Main branch');
|
|
expect(output).toContain('Feature branch');
|
|
|
|
// Press B to filter by branch
|
|
stdin.write('B');
|
|
await wait(50);
|
|
|
|
output = lastFrame();
|
|
// Only main branch sessions should be visible
|
|
expect(output).toContain('Main branch');
|
|
expect(output).toContain('Also main');
|
|
expect(output).not.toContain('Feature branch');
|
|
});
|
|
|
|
it('should combine empty session filter with branch filter', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 's1',
|
|
gitBranch: 'main',
|
|
messageCount: 0,
|
|
prompt: 'Empty main',
|
|
}),
|
|
createMockSession({
|
|
sessionId: 's2',
|
|
gitBranch: 'main',
|
|
messageCount: 5,
|
|
prompt: 'Valid main',
|
|
}),
|
|
createMockSession({
|
|
sessionId: 's3',
|
|
gitBranch: 'feature',
|
|
messageCount: 5,
|
|
prompt: 'Valid feature',
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame, stdin } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
currentBranch="main"
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
// Press B to filter by branch
|
|
stdin.write('B');
|
|
await wait(50);
|
|
|
|
const output = lastFrame();
|
|
// Should only show sessions from main branch (including 0-message sessions)
|
|
expect(output).toContain('Valid main');
|
|
expect(output).toContain('Empty main');
|
|
expect(output).not.toContain('Valid feature');
|
|
});
|
|
});
|
|
|
|
describe('Keyboard Navigation', () => {
|
|
it('should navigate with arrow keys', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 's1',
|
|
prompt: 'First session',
|
|
messageCount: 1,
|
|
}),
|
|
createMockSession({
|
|
sessionId: 's2',
|
|
prompt: 'Second session',
|
|
messageCount: 1,
|
|
}),
|
|
createMockSession({
|
|
sessionId: 's3',
|
|
prompt: 'Third session',
|
|
messageCount: 1,
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame, stdin } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
// First session should be selected initially (indicated by >)
|
|
let output = lastFrame();
|
|
expect(output).toContain('First session');
|
|
|
|
// Navigate down
|
|
stdin.write('\u001B[B'); // Down arrow
|
|
await wait(50);
|
|
|
|
output = lastFrame();
|
|
// Selection indicator should move
|
|
expect(output).toBeDefined();
|
|
});
|
|
|
|
it('should navigate with vim keys (j/k)', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 's1',
|
|
prompt: 'First',
|
|
messageCount: 1,
|
|
}),
|
|
createMockSession({
|
|
sessionId: 's2',
|
|
prompt: 'Second',
|
|
messageCount: 1,
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { stdin, unmount } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
// Navigate with j (down)
|
|
stdin.write('j');
|
|
await wait(50);
|
|
|
|
// Navigate with k (up)
|
|
stdin.write('k');
|
|
await wait(50);
|
|
|
|
unmount();
|
|
});
|
|
|
|
it('should select session on Enter', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 'selected-session',
|
|
prompt: 'Select me',
|
|
messageCount: 1,
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { stdin } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
// Press Enter to select
|
|
stdin.write('\r');
|
|
await wait(50);
|
|
|
|
expect(onSelect).toHaveBeenCalledWith('selected-session');
|
|
});
|
|
|
|
it('should cancel on Escape', async () => {
|
|
const sessions = [
|
|
createMockSession({ sessionId: 's1', messageCount: 1 }),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { stdin } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
// Press Escape to cancel
|
|
stdin.write('\u001B');
|
|
await wait(50);
|
|
|
|
expect(onCancel).toHaveBeenCalled();
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Display', () => {
|
|
it('should show session metadata', async () => {
|
|
const sessions = [
|
|
createMockSession({
|
|
sessionId: 's1',
|
|
prompt: 'Test prompt text',
|
|
messageCount: 5,
|
|
gitBranch: 'feature-branch',
|
|
}),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('Test prompt text');
|
|
expect(output).toContain('5 messages');
|
|
expect(output).toContain('feature-branch');
|
|
});
|
|
|
|
it('should show header and footer', async () => {
|
|
const sessions = [createMockSession({ messageCount: 1 })];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('Resume Session');
|
|
expect(output).toContain('↑↓ to navigate');
|
|
expect(output).toContain('Esc to cancel');
|
|
});
|
|
|
|
it('should show branch toggle hint when currentBranch is provided', async () => {
|
|
const sessions = [createMockSession({ messageCount: 1 })];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
currentBranch="main"
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('B');
|
|
expect(output).toContain('toggle branch');
|
|
});
|
|
|
|
it('should truncate long prompts', async () => {
|
|
const longPrompt = 'A'.repeat(300);
|
|
const sessions = [
|
|
createMockSession({ prompt: longPrompt, messageCount: 1 }),
|
|
];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
// Should contain ellipsis for truncated text
|
|
expect(output).toContain('...');
|
|
// Should NOT contain the full untruncated prompt (300 A's in a row)
|
|
expect(output).not.toContain(longPrompt);
|
|
});
|
|
|
|
it('should show "(empty prompt)" for sessions without prompt text', async () => {
|
|
const sessions = [createMockSession({ prompt: '', messageCount: 1 })];
|
|
const mockService = createMockSessionService(sessions);
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { lastFrame } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(100);
|
|
|
|
const output = lastFrame();
|
|
expect(output).toContain('(empty prompt)');
|
|
});
|
|
});
|
|
|
|
describe('Pagination', () => {
|
|
it('should load more sessions when scrolling to bottom', async () => {
|
|
const firstPage = Array.from({ length: 5 }, (_, i) =>
|
|
createMockSession({
|
|
sessionId: `session-${i}`,
|
|
prompt: `Session ${i}`,
|
|
messageCount: 1,
|
|
mtime: Date.now() - i * 1000,
|
|
}),
|
|
);
|
|
const secondPage = Array.from({ length: 3 }, (_, i) =>
|
|
createMockSession({
|
|
sessionId: `session-${i + 5}`,
|
|
prompt: `Session ${i + 5}`,
|
|
messageCount: 1,
|
|
mtime: Date.now() - (i + 5) * 1000,
|
|
}),
|
|
);
|
|
|
|
const mockService = {
|
|
listSessions: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
items: firstPage,
|
|
hasMore: true,
|
|
nextCursor: Date.now() - 5000,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
items: secondPage,
|
|
hasMore: false,
|
|
nextCursor: undefined,
|
|
}),
|
|
loadSession: vi.fn(),
|
|
loadLastSession: vi.fn().mockResolvedValue({}),
|
|
};
|
|
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
const { unmount } = render(
|
|
<KeypressProvider kittyProtocolEnabled={false}>
|
|
<SessionPicker
|
|
sessionService={mockService as never}
|
|
onSelect={onSelect}
|
|
onCancel={onCancel}
|
|
/>
|
|
</KeypressProvider>,
|
|
);
|
|
|
|
await wait(200);
|
|
|
|
// First page should be loaded
|
|
expect(mockService.listSessions).toHaveBeenCalled();
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|