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 = ''; -const ARROW_UP = ''; // 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 = [