diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
index 3a8bef6c5..7a73cf651 100644
--- a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
+++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
@@ -31,9 +31,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
// inside string literals.
const CTRL_B = '';
const ESC = '';
-const BACKSPACE = '';
const ARROW_DOWN = '[B';
-const ARROW_UP = '[A';
// Mock terminal size
const mockTerminalSize = { columns: 80, rows: 24 };
@@ -407,971 +405,6 @@ describe('SessionPicker', () => {
});
});
- describe('Search', () => {
- it('substring filters across customTitle, prompt, and gitBranch', async () => {
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'completely unrelated',
- customTitle: 'login bug investigation',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's2',
- prompt: 'review login flow',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's3',
- prompt: 'totally different',
- gitBranch: 'feature/login-revamp',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's4',
- prompt: 'unrelated work',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
-
- // '/' enters search explicitly so the query starts fresh and
- // shortcut letters in the term (none here) don't get reinterpreted.
- stdin.write('/login');
- await wait(50);
-
- const output = lastFrame() ?? '';
- // s1 matches via customTitle, s2 via prompt, s3 via gitBranch.
- expect(output).toContain('login bug investigation');
- expect(output).toContain('review login flow');
- expect(output).toContain('feature/login-revamp');
- // The non-matching session must drop out.
- expect(output).not.toContain('unrelated work');
- // The visible search row reflects the active query.
- expect(output).toContain('Search:');
- });
-
- it('typing a non-shortcut char enters search mode implicitly', async () => {
- // Letters that are not list-mode shortcuts (anything other than j,
- // k, b, B, ' ', '/') seed the search query directly.
- const sessions = [
- createMockSession({
- sessionId: 'a',
- prompt: 'deploy review',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'b',
- // Picked so it shares no letters with the typed query 'dep'.
- prompt: 'cluster setup',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('dep');
- await wait(50);
-
- const output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).toContain('deploy review');
- expect(output).not.toContain('cluster setup');
- });
-
- it('list-mode j/k navigate the list', async () => {
- // vim shortcuts stay live in list mode even though the rest of
- // the alphabet seeds search.
- 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 { stdin } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('j'); // selectedIndex 0 -> 1
- await wait(30);
- stdin.write('\r');
- await wait(50);
- expect(onSelect).toHaveBeenLastCalledWith('s2');
-
- stdin.write('k'); // back to 0
- await wait(30);
- stdin.write('\r');
- await wait(50);
- expect(onSelect).toHaveBeenLastCalledWith('s1');
- });
-
- it("'b' seeds search; once searching, 'j' appends to the query", async () => {
- // Lowercase letters that aren't list-mode shortcuts ('b' here)
- // implicitly enter search. Once in search mode, j and k lose
- // their nav meaning and behave as regular query characters so
- // titles containing 'j' or 'k' can be searched.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'bjorn config',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's2',
- prompt: 'bug investigation',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's3',
- prompt: 'unrelated',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- // 'b' implicitly enters search. Both s1 and s2 contain 'b'.
- stdin.write('b');
- await wait(30);
- let output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).toContain('bjorn config');
- expect(output).toContain('bug investigation');
- expect(output).not.toContain('unrelated');
-
- // 'j' inside search appends to the query → "bj" — only s1 still
- // matches. If 'j' were still bound to nav we would see the same
- // 'b'-filtered list (two matches), and selectedIndex would drift
- // instead of the matches narrowing.
- stdin.write('j');
- await wait(50);
- output = lastFrame() ?? '';
- expect(output).toContain('bjorn config');
- expect(output).not.toContain('bug investigation');
- });
-
- it('list-mode Space without preview enabled is a no-op', async () => {
- // When preview is disabled, the Space-as-preview shortcut never
- // fires; Space is also explicitly skipped from implicit search
- // entry to keep leading whitespace out of the query.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'first',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write(' ');
- await wait(50);
-
- const output = lastFrame() ?? '';
- expect(output).toContain('Press / to search');
- expect(output).not.toContain('Search:');
- });
-
- it('Backspace edits the query; emptying it returns to list mode', async () => {
- // Backspace is an edit op that, when it deletes the final char,
- // also flips back to list mode so the shortcut keymap is
- // immediately available again. Esc remains the explicit exit.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'login bug',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's2',
- prompt: 'unrelated',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
- const onCancel = vi.fn();
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
-
- stdin.write('/logim');
- await wait(30);
- stdin.write(BACKSPACE);
- await wait(30);
- stdin.write('n');
- await wait(50);
-
- let output = lastFrame() ?? '';
- expect(output).toContain('login bug');
- expect(output).not.toContain('unrelated');
-
- // First Esc: exit search back to list, do not cancel.
- stdin.write(ESC);
- await wait(30);
- output = lastFrame() ?? '';
- expect(output).toContain('login bug');
- expect(output).toContain('unrelated');
- expect(output).toContain('Press / to search');
- expect(onCancel).not.toHaveBeenCalled();
-
- // Second Esc: now actually cancels.
- stdin.write(ESC);
- await wait(30);
- expect(onCancel).toHaveBeenCalledTimes(1);
- });
-
- it('Backspace in list mode does not spawn a search', async () => {
- // Regression: Backspace's raw byte (DEL, 0x7F) used to slip past
- // the printable-char filter and seed an implicit search with the
- // literal DEL byte, producing a confusing 'No sessions match …'
- // frame in list mode. List-mode Backspace must be inert.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'first',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write(BACKSPACE);
- await wait(50);
-
- const output = lastFrame() ?? '';
- // Still in list mode — no search frame, no spurious empty match.
- expect(output).toContain('Press / to search');
- expect(output).not.toContain('Search:');
- expect(output).not.toContain('No sessions match');
- // The session list is still rendered untouched.
- expect(output).toContain('first');
- });
-
- it('search mode suppresses the row highlight', async () => {
- // The "›" selected-prefix and accent color belong to the row
- // the user is about to act on. While they're still typing the
- // query, no row should claim that affordance — the search input
- // owns focus exclusively until ↑↓/Enter commits to the list.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'login flow',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's2',
- prompt: 'login bug',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- // Sanity: in list mode the first row is highlighted with '› '.
- let output = lastFrame() ?? '';
- expect(output).toContain('› ');
-
- // Enter search; the highlight should disappear.
- stdin.write('/login');
- await wait(50);
- output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).not.toContain('› ');
-
- // Commit (↓ or Enter) reinstates the highlight on the list.
- stdin.write(ARROW_DOWN);
- await wait(30);
- output = lastFrame() ?? '';
- expect(output).toContain('Filter:');
- expect(output).toContain('› ');
- });
-
- it('Enter in search commits the filter; second Enter selects', async () => {
- // Defensive UX: the user's typing reflex shouldn't accidentally
- // resume a session. Pressing Enter while still in the search
- // input commits the filter (drops to list, query preserved)
- // and onSelect stays unfired. Only a deliberate second Enter
- // from the list view actually resumes.
- const sessions = [
- createMockSession({
- sessionId: 'first',
- prompt: 'foo',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'matching',
- prompt: 'special-target',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
- const onSelect = vi.fn();
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/special');
- await wait(30);
-
- // First Enter: search → list, query stays applied, no resume.
- stdin.write('\r');
- await wait(30);
- expect(onSelect).not.toHaveBeenCalled();
- const afterFirstEnter = lastFrame() ?? '';
- expect(afterFirstEnter).toContain('Filter:');
- expect(afterFirstEnter).toContain('special-target');
-
- // Second Enter from list view selects the highlighted row.
- stdin.write('\r');
- await wait(30);
- expect(onSelect).toHaveBeenCalledWith('matching');
- });
-
- it('Enter in search with no matches stays in search', async () => {
- // Don't drop the user out of the search input on Enter when
- // there's nothing to commit to — they're mid-typo and need
- // to keep editing.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'unrelated',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
- const onSelect = vi.fn();
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/zzz'); // matches nothing
- await wait(30);
- stdin.write('\r');
- await wait(30);
-
- const output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).toContain('No sessions match');
- expect(onSelect).not.toHaveBeenCalled();
- });
-
- it('↑/↓ from search drops to list mode while keeping the filter', async () => {
- // The post-narrow state: user types to filter, then arrows to
- // pick a row. Once they navigate, the search frame goes away
- // (no more caret, switches to "Filter:" indicator) but the
- // query stays applied so the list remains narrowed and full
- // list-mode shortcuts work on the highlighted row.
- const sessions = [
- createMockSession({
- sessionId: 'a',
- prompt: 'login flow review',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'b',
- prompt: 'login bug fix',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'c',
- prompt: 'unrelated',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(30);
-
- let output = lastFrame() ?? '';
- // In search mode: caret-bearing "Search:" row is visible.
- expect(output).toContain('Search:');
-
- stdin.write(ARROW_DOWN);
- await wait(50);
-
- output = lastFrame() ?? '';
- // Now in list mode with filter preserved: the read-only
- // "Filter:" indicator replaces "Search:", but the list is
- // still narrowed to the two login matches.
- expect(output).toContain('Filter:');
- expect(output).not.toContain('Search:');
- expect(output).toContain('login flow review');
- expect(output).toContain('login bug fix');
- expect(output).not.toContain('unrelated');
- });
-
- it('Space → preview works on the highlighted row in filtered-list', async () => {
- // Once narrowed and out of search, Space should trigger the
- // preview shortcut just like in the unfiltered list — proves
- // the action is mode-independent.
- const sessions = [
- createMockSession({
- sessionId: 'a',
- prompt: 'login flow',
- messageCount: 2,
- }),
- createMockSession({
- sessionId: 'b',
- prompt: 'unrelated',
- messageCount: 2,
- }),
- ];
- const service = createMockSessionService(sessions);
- service.loadSession.mockResolvedValue({
- conversation: { messages: [] },
- filePath: '/x',
- lastCompletedUuid: null,
- });
-
- const { stdin, lastFrame } = render(
-
- false,
- getIdeMode: () => false,
- isTrustedFolder: () => false,
- } as unknown as Config
- }
- >
-
-
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(30);
- stdin.write(ARROW_DOWN); // exits search, cursor on filtered first item
- await wait(30);
- stdin.write(' '); // Space → preview
- await wait(150);
-
- const frame = lastFrame() ?? '';
- // Preview frame shows session metadata (prompt, message count).
- expect(frame).toContain('login flow');
- // The filter row / list view is replaced by the preview, which
- // does not render the "Search:" or "Filter:" rows.
- expect(frame).not.toContain('Filter:');
- });
-
- it('Ctrl+B toggles branch in filtered-list', async () => {
- // Branch toggle stays available after narrowing, so users can
- // refine filter axes without losing their query.
- const sessions = [
- createMockSession({
- sessionId: 'a',
- gitBranch: 'main',
- prompt: 'login',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'b',
- gitBranch: 'feature',
- prompt: 'login on feature',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(30);
- stdin.write(ARROW_DOWN); // exit search to filtered-list
- await wait(30);
-
- // Both still visible (filter='login' matches both).
- let output = lastFrame() ?? '';
- expect(output).toContain('login on feature');
-
- stdin.write(CTRL_B);
- await wait(50);
-
- output = lastFrame() ?? '';
- // Branch filter narrows to main; query still applied.
- expect(output).toContain('Filter:');
- expect(output).toContain('login');
- expect(output).not.toContain('login on feature');
- });
-
- it('Esc in filtered-list clears the query first, then cancels', async () => {
- // Two-stage Esc parity with search mode: pressing Esc once on
- // the filtered list drops the query (returning the unfiltered
- // list) while keeping the picker open; a second Esc finally
- // cancels. Avoids an "I lost my filter AND closed the dialog"
- // surprise from a single accidental keystroke.
- const sessions = [
- createMockSession({
- sessionId: 'a',
- prompt: 'login flow',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'b',
- prompt: 'unrelated',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
- const onCancel = vi.fn();
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(30);
- stdin.write(ARROW_DOWN); // → filtered-list with q='login'
- await wait(30);
-
- let output = lastFrame() ?? '';
- expect(output).toContain('Filter:');
-
- // First Esc: drop the filter, stay open.
- stdin.write(ESC);
- await wait(30);
- output = lastFrame() ?? '';
- expect(output).toContain('Press / to search');
- expect(output).toContain('login flow');
- expect(output).toContain('unrelated');
- expect(onCancel).not.toHaveBeenCalled();
-
- // Second Esc: actually cancel.
- stdin.write(ESC);
- await wait(30);
- expect(onCancel).toHaveBeenCalledTimes(1);
- });
-
- it("'/' from filtered-list preserves the existing query", async () => {
- // Re-focusing search via '/' must not throw away what the user
- // already typed — they typically hit '/' when they want to
- // tweak the filter, not start over. Esc is the explicit clear
- // gesture (covered by the Backspace/Esc test above).
- const sessions = [
- createMockSession({
- sessionId: 'a',
- prompt: 'login flow',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'b',
- prompt: 'unrelated',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(30);
- stdin.write(ARROW_DOWN); // exit to filtered-list
- await wait(30);
-
- // Re-press '/' from filtered-list: viewMode flips back to
- // search but the query stays.
- stdin.write('/');
- await wait(30);
-
- const output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).toContain('login');
- // Filter is still applied — non-matches stay filtered out.
- expect(output).not.toContain('unrelated');
- });
-
- it('↑ at top of unfiltered list also wraps into search', async () => {
- // Same boundary-wrap pattern as the filtered case: a fresh
- // picker (no query yet) lets the user kick off a search just
- // by hitting ↑ from the first row, no '/' required.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'first',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 's2',
- prompt: 'second',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- // Already at index 0 from the initial render; ↑ wraps into search.
- stdin.write(ARROW_UP);
- await wait(50);
-
- const output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- // No query yet, so the list is unfiltered — both rows visible.
- expect(output).toContain('first');
- expect(output).toContain('second');
- });
-
- it('↑ at top of filtered-list wraps focus back to search', async () => {
- // fzf-style boundary wrap: the search row is treated as a row
- // above the list, so pressing ↑ when already on the first
- // filtered match returns the user to search-mode editing
- // without needing another '/' keystroke. ↓ at the bottom is
- // intentionally NOT wrapped — that's the loadMore sentinel.
- const sessions = [
- createMockSession({
- sessionId: 'a',
- prompt: 'login flow',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'b',
- prompt: 'login bug',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(30);
-
- // ↓ from search exits to filtered-list at the first match
- // (selectedIndex was reset to 0 when the query changed, and
- // ↓ no longer advances past it).
- stdin.write(ARROW_DOWN);
- await wait(30);
- let output = lastFrame() ?? '';
- expect(output).toContain('Filter:');
-
- // ↑ at index 0 wraps focus right back into search.
- stdin.write(ARROW_UP);
- await wait(30);
- output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).not.toContain('Filter:');
- // Query is preserved so the user is editing the same filter.
- expect(output).toContain('login');
- });
-
- it('↓ from search lands on the first match, not the second', async () => {
- // Regression: previously ↓ from search did setViewMode('list')
- // *and* advanced selectedIndex, so the user pressed ↓ once and
- // jumped past the first (highest-relevance) match. Now ↓
- // simply commits the focus transition; selectedIndex stays at
- // 0 (already reset by the query-change effect).
- //
- // Inter-key waits are 50ms (not the 30ms used elsewhere): on
- // Windows runners the keypress → useEffect → render chain
- // through `/login` + ARROW_DOWN + Enter consistently exceeded
- // 30ms and dropped the Enter event — the spy never saw the
- // selection. Tests in this file already use 50ms in similar
- // multi-step sequences; align with that.
- const sessions = [
- createMockSession({
- sessionId: 'first-match',
- prompt: 'login flow',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'second-match',
- prompt: 'login bug',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
- const onSelect = vi.fn();
-
- const { stdin } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(50);
- stdin.write(ARROW_DOWN); // exit search → first match should be highlighted
- await wait(50);
- stdin.write('\r'); // Enter from list = select highlighted row
- await wait(50);
-
- expect(onSelect).toHaveBeenCalledWith('first-match');
- });
-
- it('↑/↓ are a no-op in search when the query matches nothing', async () => {
- // Sentinel for the "phantom mode-switch" glitch: when the
- // current query has zero matches there is no row to land on,
- // so ↑/↓ must not silently flip the picker out of search mode.
- // The user keeps editing the query (or backs up) until the
- // filter actually finds something.
- const sessions = [
- createMockSession({
- sessionId: 's1',
- prompt: 'unrelated work',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- // Type a query that matches nothing.
- stdin.write('/zzznomatch');
- await wait(50);
- let output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).toContain('No sessions match');
-
- // ↑↓ should not exit search.
- stdin.write(ARROW_DOWN);
- await wait(30);
- output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).not.toContain('Filter:');
-
- stdin.write(ARROW_UP);
- await wait(30);
- output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).not.toContain('Filter:');
- });
-
- it('typing in filtered-list re-enters search and appends', async () => {
- // After narrowing → arrow → list, further typing should refine
- // (append to existing query) rather than start a fresh search.
- // Prompts use no spaces so the substring "loginbug" / "loginflow"
- // can match contiguously without colliding with Space-as-preview.
- const sessions = [
- createMockSession({
- sessionId: 'a',
- prompt: 'loginflow',
- messageCount: 1,
- }),
- createMockSession({
- sessionId: 'b',
- prompt: 'loginbug',
- messageCount: 1,
- }),
- ];
- const mockService = createMockSessionService(sessions);
-
- const { stdin, lastFrame } = render(
-
-
- ,
- );
-
- await wait(100);
- stdin.write('/login');
- await wait(30);
- stdin.write(ARROW_DOWN);
- await wait(30);
- // Now list-mode + query='login'. Type 'bug' — implicit entry
- // re-enters search and appends, yielding query='loginbug'.
- stdin.write('bug');
- await wait(50);
-
- const output = lastFrame() ?? '';
- expect(output).toContain('Search:');
- expect(output).toContain('loginbug');
- expect(output).not.toContain('loginflow');
- });
- });
-
describe('Display', () => {
it('should show session metadata', async () => {
const sessions = [