diff --git a/.changeset/sdk-list-sessions-page.md b/.changeset/sdk-list-sessions-page.md new file mode 100644 index 00000000000..39227f49331 --- /dev/null +++ b/.changeset/sdk-list-sessions-page.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add `listSessionsPage` for keyset-paged session listing (`limit` / `before`, returns `nextCursor`). The v2 engine pages through the session index; the v1 engine keeps answering with a single full page. diff --git a/.changeset/session-picker-pagination.md b/.changeset/session-picker-pagination.md new file mode 100644 index 00000000000..83ec5e0d851 --- /dev/null +++ b/.changeset/session-picker-pagination.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Page the /sessions picker list so it opens fast with large session counts. diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index c8bd9017b5a..75c86687ffc 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -89,6 +89,8 @@ export class SessionPickerComponent extends Container implements Focusable { private visibleCount: number; private scope: 'cwd' | 'all'; private loading: boolean; + private hasMore: boolean; + private loadingMore: boolean; private list: SearchableList; focused = false; @@ -106,6 +108,14 @@ export class SessionPickerComponent extends Container implements Focusable { onCtrlD?: () => void; onToggleScope?: (selectedSessionId: string) => void; maxVisibleSessions?: number; + /** More pages exist on the backend (keyset paging). */ + hasMore?: boolean; + /** A follow-up page fetch is in flight. */ + loadingMore?: boolean; + /** Fired when the cursor reaches the end of every row fetched so far. */ + onLoadMore?: () => void; + /** Fired when a search query becomes active while pages remain unfetched. */ + onSearchDrain?: () => void; }) { super(); this.sessions = opts.sessions; @@ -117,6 +127,10 @@ export class SessionPickerComponent extends Container implements Focusable { this.onToggleScope = opts.onToggleScope; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; this.pageSize = Math.max(1, opts.pageSize ?? 50); + this.hasMore = opts.hasMore ?? false; + this.loadingMore = opts.loadingMore ?? false; + this.onLoadMore = opts.onLoadMore; + this.onSearchDrain = opts.onSearchDrain; const initialIndex = this.resolveInitialSelectedIndex(opts.initialSelectedSessionId); this.list = new SearchableList({ items: this.sessions, @@ -133,6 +147,26 @@ export class SessionPickerComponent extends Container implements Focusable { private readonly onCtrlC?: () => void; private readonly onCtrlD?: () => void; + private readonly onLoadMore?: () => void; + private readonly onSearchDrain?: () => void; + + /** Appends a freshly fetched page, keeping the cursor and active query. */ + appendSessions(rows: SessionRow[]): void { + this.sessions = [...this.sessions, ...rows]; + this.list.setItems(this.sessions); + // Rows arriving while a query is active must become visible without + // waiting for the next keypress; only grow, never shrink the window. + this.visibleCount = Math.max( + this.visibleCount, + Math.min(this.list.view().items.length, this.pageSize), + ); + } + + /** Updates the backend-paging facts after an in-flight fetch settles. */ + setPaging(hasMore: boolean, loadingMore: boolean): void { + this.hasMore = hasMore; + this.loadingMore = loadingMore; + } private resolveInitialSelectedIndex(initialSelectedSessionId: string | undefined): number { if (initialSelectedSessionId === undefined) return 0; @@ -152,6 +186,11 @@ export class SessionPickerComponent extends Container implements Focusable { const view = this.list.view(); if (view.query !== previousQuery) { this.visibleCount = Math.min(view.items.length, this.pageSize); + // A fresh query only searches the pages fetched so far; ask the host to + // drain the rest in the background so search covers every session. + if (view.query.length > 0 && previousQuery.length === 0 && this.hasMore) { + this.onSearchDrain?.(); + } return; } @@ -159,6 +198,15 @@ export class SessionPickerComponent extends Container implements Focusable { if (view.selectedIndex >= loadedCount - 1 && loadedCount < view.items.length) { this.visibleCount = Math.min(view.items.length, this.visibleCount + this.pageSize); } + // The cursor reached the end of everything fetched: pull the next page. + if ( + this.hasMore && + !this.loadingMore && + view.items.length > 0 && + view.selectedIndex >= view.items.length - 1 + ) { + this.onLoadMore?.(); + } } handleInput(data: string): void { @@ -287,15 +335,29 @@ export class SessionPickerComponent extends Container implements Focusable { } const filteredCount = view.items.length; - if (loadedSessions.length > visibleSessions.length || view.query.length > 0) { + if ( + loadedSessions.length > visibleSessions.length || + view.query.length > 0 || + this.hasMore || + this.loadingMore + ) { lines.push(''); + const moreSuffix = this.loadingMore + ? ' · loading more…' + : this.hasMore + ? view.query.length > 0 + ? ' · searching all…' + : ' · scroll for more' + : ''; const totalSuffix = view.query.length > 0 ? `${String(loadedSessions.length)} loaded / ${String(filteredCount)} matches` - : loadedSessions.length === this.sessions.length - ? `${String(loadedSessions.length)} sessions` - : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; - const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}`; + : this.hasMore || this.loadingMore + ? `${String(loadedSessions.length)} loaded` + : loadedSessions.length === this.sessions.length + ? `${String(loadedSessions.length)} sessions` + : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; + const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}${moreSuffix}`; lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index 4539d1b9fe7..64232353962 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -16,6 +16,9 @@ export const EXIT_CONFIRM_WINDOW_MS = 1500; // presses far apart don't accidentally trigger undo. export const DOUBLE_ESC_WINDOW_MS = 600; +/** Session picker page size: one backend keyset page and one picker window. */ +export const SESSION_LIST_PAGE_SIZE = 50; + export function isManagedUsageProvider( providerKey: string | undefined, ): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index c1254bce89c..2b6495e0401 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -105,6 +105,7 @@ import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, PRODUCT_NAME, + SESSION_LIST_PAGE_SIZE, SESSIONLESS_STARTUP_NOTICE, } from './constant/kimi-tui'; import { CHROME_GUTTER } from './constant/rendering'; @@ -885,8 +886,10 @@ export class KimiTUI { }); shouldReplayHistory = true; } else { - const sessions = await this.harness.listSessions({ workDir }); - const target = sessions[0]; + // Only the most recent session matters here — fetch a one-item page + // instead of materializing the whole listing. + const page = await this.harness.listSessionsPage({ workDir, limit: 1 }); + const target = page.items[0]; if (target !== undefined) { session = await this.harness.resumeSession({ id: target.id, @@ -1972,13 +1975,16 @@ export class KimiTUI { async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise { this.state.loadingSessions = true; this.state.sessionsScope = scope; + this.state.sessionsNextCursor = undefined; + this.state.sessionsLoadingMore = false; try { - const sessions = - scope === 'all' - ? await this.harness.listSessions({}) - : await this.harness.listSessions({ workDir: this.state.appState.workDir }); + const page = await this.harness.listSessionsPage({ + workDir: scope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + }); + this.state.sessionsNextCursor = page.nextCursor; this.state.sessions = sessionRowsForPicker( - sessions, + page.items, this.state.appState.sessionId, this.hasSessionContent(), ); @@ -1992,6 +1998,81 @@ export class KimiTUI { } } + /** + * Pulls the next keyset page into the session picker (scroll-bottom paging). + * A scope switch or picker close bumps `sessionPickerScopeRequestToken`, + * which makes an in-flight append discard its result. Returns whether a page + * was appended — callers draining pages stop on the first `false`. + * Scroll triggers pass no argument and are dropped while a fetch is running; + * the search drain passes `waitForInFlight` to join the running fetch and + * continue with the next page, so a query typed mid-fetch still ends up + * covering every session. + */ + private async fetchMoreSessions(waitForInFlight = false): Promise { + while (this.sessionsPageFetchInFlight !== undefined) { + if (!waitForInFlight) return false; + await this.sessionsPageFetchInFlight; + } + const cursor = this.state.sessionsNextCursor; + if (cursor === undefined) return false; + const requestToken = this.sessionPickerScopeRequestToken; + this.state.sessionsLoadingMore = true; + this.sessionPickerComponent?.setPaging(true, true); + this.state.ui.requestRender(); + const run = this.appendNextSessionPage(cursor, requestToken); + this.sessionsPageFetchInFlight = run; + try { + return await run; + } finally { + if (this.sessionsPageFetchInFlight === run) this.sessionsPageFetchInFlight = undefined; + } + } + + private async appendNextSessionPage(cursor: string, requestToken: number): Promise { + try { + const page = await this.harness.listSessionsPage({ + workDir: this.state.sessionsScope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + before: cursor, + }); + if (requestToken !== this.sessionPickerScopeRequestToken) return false; + this.state.sessionsNextCursor = page.nextCursor; + const rows = sessionRowsForPicker( + page.items, + this.state.appState.sessionId, + this.hasSessionContent(), + ); + this.state.sessions = [...this.state.sessions, ...rows]; + this.sessionPickerComponent?.appendSessions(rows); + this.sessionPickerComponent?.setPaging(page.nextCursor !== undefined, false); + return true; + } catch (error) { + log.warn('failed to fetch more sessions for picker', { error: String(error) }); + return false; + } finally { + if (requestToken === this.sessionPickerScopeRequestToken) { + this.state.sessionsLoadingMore = false; + this.sessionPickerComponent?.setPaging(this.state.sessionsNextCursor !== undefined, false); + this.state.ui.requestRender(); + } + } + } + + /** + * Search covers every session: while a query is active the picker asks for + * all remaining pages, drained one at a time in the background. A failed or + * superseded fetch stops the drain (the next fresh query re-triggers it). + */ + private async drainSessionsForSearch(): Promise { + const requestToken = this.sessionPickerScopeRequestToken; + while ( + this.state.sessionsNextCursor !== undefined && + requestToken === this.sessionPickerScopeRequestToken + ) { + if (!(await this.fetchMoreSessions(true))) return; + } + } + updateTerminalTitle(): void { const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; const label = trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; @@ -3233,6 +3314,8 @@ export class KimiTUI { forwardEditorExit: false, }; private sessionPickerScopeRequestToken = 0; + private sessionPickerComponent: SessionPickerComponent | undefined; + private sessionsPageFetchInFlight: Promise | undefined; async showSessionPicker(): Promise { await this.openSessionPicker({ @@ -3304,6 +3387,7 @@ export class KimiTUI { hideSessionPicker(): void { this.sessionPickerScopeRequestToken += 1; + this.sessionPickerComponent = undefined; this.editorKeyboard.clearPendingExit(); this.state.activeDialog = null; this.restoreEditor(); @@ -3324,29 +3408,37 @@ export class KimiTUI { readonly applyStartupModes?: boolean; }): void { this.state.activeDialog = 'session-picker'; - this.mountEditorReplacement( - new SessionPickerComponent({ - sessions: this.state.sessions, - loading: this.state.loadingSessions, - currentSessionId: this.state.appState.sessionId, - scope: this.state.sessionsScope, - initialSelectedSessionId: options.initialSelectedSessionId, - pageSize: 50, - onSelect: (session: SessionRow) => { - void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( - (error) => { - this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); - }, - ); - }, - onCancel: options.onCancel, - onCtrlC: options.onCtrlC, - onCtrlD: options.onCtrlD, - onToggleScope: (selectedSessionId: string) => { - void this.toggleSessionPickerScope(selectedSessionId); - }, - }), - ); + const picker = new SessionPickerComponent({ + sessions: this.state.sessions, + loading: this.state.loadingSessions, + currentSessionId: this.state.appState.sessionId, + scope: this.state.sessionsScope, + initialSelectedSessionId: options.initialSelectedSessionId, + pageSize: SESSION_LIST_PAGE_SIZE, + hasMore: this.state.sessionsNextCursor !== undefined, + loadingMore: this.state.sessionsLoadingMore, + onLoadMore: () => { + void this.fetchMoreSessions(); + }, + onSearchDrain: () => { + void this.drainSessionsForSearch(); + }, + onSelect: (session: SessionRow) => { + void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + (error) => { + this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); + }, + ); + }, + onCancel: options.onCancel, + onCtrlC: options.onCtrlC, + onCtrlD: options.onCtrlD, + onToggleScope: (selectedSessionId: string) => { + void this.toggleSessionPickerScope(selectedSessionId); + }, + }); + this.sessionPickerComponent = picker; + this.mountEditorReplacement(picker); } private async handleSessionPickerSelect( diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 349ecbdea8a..589d79c579f 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -47,6 +47,10 @@ export interface TUIState { toolOutputExpanded: boolean; sessions: SessionRow[]; loadingSessions: boolean; + /** Keyset cursor for the next older page; `undefined` when the listing is exhausted. */ + sessionsNextCursor: string | undefined; + /** A follow-up session page fetch is in flight. */ + sessionsLoadingMore: boolean; sessionsScope: 'cwd' | 'all'; activeDialog: 'session-picker' | 'help' | 'trust-prompt' | 'cache-hint' | null; tasksBrowser: TasksBrowserState | undefined; @@ -105,6 +109,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { toolOutputExpanded: false, sessions: [], loadingSessions: false, + sessionsNextCursor: undefined, + sessionsLoadingMore: false, sessionsScope: 'cwd', activeDialog: null, tasksBrowser: undefined, diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts index 00a920e1ffd..20770338038 100644 --- a/apps/kimi-code/src/tui/utils/searchable-list.ts +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -38,7 +38,7 @@ export interface SearchableListView { } export class SearchableList { - private readonly items: readonly T[]; + private items: readonly T[]; private readonly toSearchText: (item: T) => string; private readonly pageSize: number; private readonly searchable: boolean; @@ -53,6 +53,15 @@ export class SearchableList { this.cursor = Math.max(opts.initialIndex ?? 0, 0); } + /** + * Replaces the item set (e.g. after another page was appended), keeping the + * active query; the cursor is clamped into the new range. + */ + setItems(items: readonly T[]): void { + this.items = items; + this.cursor = Math.min(this.cursor, Math.max(0, items.length - 1)); + } + filtered(): readonly T[] { if (this.query.length === 0) return this.items; return fuzzyFilter([...this.items], this.query, this.toSearchText); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 3c885488b25..222adfa6a59 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -709,4 +709,164 @@ describe('SessionPickerComponent', () => { expect(onToggleScope).toHaveBeenCalledOnce(); expect(onToggleScope).toHaveBeenCalledWith('ses_beta'); }); + + it('fires onLoadMore when the cursor reaches the last fetched row', () => { + const onLoadMore = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onLoadMore, + }); + + component.handleInput('\u001B[B'); + + expect(onLoadMore).toHaveBeenCalledOnce(); + }); + + it('does not fire onLoadMore while a page fetch is in flight', () => { + const onLoadMore = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + loadingMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onLoadMore, + }); + + component.handleInput('\u001B[B'); + + expect(onLoadMore).not.toHaveBeenCalled(); + }); + + it('appendSessions extends the list and keeps the active query', () => { + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('g'); + expect(renderPlain(component)).toContain('No matches'); + + component.appendSessions([ + { id: 'ses_gamma', title: 'Gamma session', work_dir: '/tmp/p', updated_at: 2 }, + ]); + + const output = renderPlain(component); + expect(output).toContain('Search: g'); + expect(output).toContain('Gamma session'); + expect(output).not.toContain('Alpha session'); + }); + + it('appendSessions keeps the selected row', () => { + const onSelect = vi.fn(); + const beta = { id: 'ses_beta', title: 'Beta session', work_dir: '/tmp/p', updated_at: 2 }; + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }, + beta, + ], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('\u001B[B'); + component.appendSessions([ + { id: 'ses_gamma', title: 'Gamma session', work_dir: '/tmp/p', updated_at: 3 }, + ]); + component.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(beta); + }); + + it('fires onSearchDrain only when the query becomes active with unfetched pages', () => { + const onSearchDrain = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onSearchDrain, + }); + + component.handleInput('a'); + component.handleInput('l'); + + expect(onSearchDrain).toHaveBeenCalledOnce(); + }); + + it('does not fire onSearchDrain when every page is already fetched', () => { + const onSearchDrain = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onSearchDrain, + }); + + component.handleInput('a'); + + expect(onSearchDrain).not.toHaveBeenCalled(); + }); + + it('announces unfetched pages and in-flight fetches in the footer', () => { + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(renderPlain(component)).toContain('· scroll for more'); + + component.setPaging(true, true); + expect(renderPlain(component)).toContain('· loading more…'); + + component.setPaging(false, false); + const settled = renderPlain(component); + expect(settled).not.toContain('· scroll for more'); + expect(settled).not.toContain('· loading more…'); + }); + + it('notes the background drain in the footer while searching with unfetched pages', () => { + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('a'); + + expect(renderPlain(component)).toContain('· searching all…'); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index acd25f34adf..619ecf2c2f4 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -268,7 +268,7 @@ function makeSession(overrides: Record = {}) { function makeHarness(session = makeSession(), overrides: Record = {}) { const interactiveAgentScope = new AsyncLocalStorage(); - return { + const harness = { getConfig: vi.fn(async () => ({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, @@ -315,6 +315,23 @@ function makeHarness(session = makeSession(), overrides: Record }, ...overrides, }; + // The TUI lists sessions through keyset pages; derive the page mock from + // the (possibly overridden) full-list mock unless a test overrides paging. + if (!('listSessionsPage' in harness)) { + const listSessions = harness.listSessions as (input?: { + workDir?: string; + sessionId?: string; + }) => Promise; + Object.assign(harness, { + listSessionsPage: vi.fn( + async (input: { workDir?: string; sessionId?: string } = {}) => ({ + items: await listSessions({ workDir: input.workDir, sessionId: input.sessionId }), + nextCursor: undefined, + }), + ), + }); + } + return harness; } async function makeDriver( diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 2e76c044029..a621fdaba6e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -193,7 +193,7 @@ function loginRequiredError(): Error & { readonly code: string } { } function makeHarness(session = makeSession(), overrides: Record = {}) { - return { + const harness = { getConfig: vi.fn(async () => ({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, @@ -215,6 +215,23 @@ function makeHarness(session = makeSession(), overrides: Record }, ...overrides, }; + // The TUI lists sessions through keyset pages; derive the page mock from + // the (possibly overridden) full-list mock unless a test overrides paging. + if (!('listSessionsPage' in harness)) { + const listSessions = harness.listSessions as (input?: { + workDir?: string; + sessionId?: string; + }) => Promise; + Object.assign(harness, { + listSessionsPage: vi.fn( + async (input: { workDir?: string; sessionId?: string } = {}) => ({ + items: await listSessions({ workDir: input.workDir, sessionId: input.sessionId }), + nextCursor: undefined, + }), + ), + }); + } + return harness; } function makeDriver(harness: ReturnType, input: KimiTUIStartupInput) { @@ -1057,6 +1074,127 @@ describe('KimiTUI startup', () => { expect(mountSessionPicker).toHaveBeenCalledTimes(1); }); + function makePagedListSessionsPage() { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + id: `ses-page1-${String(index).padStart(2, '0')}`, + workDir: '/tmp/proj-a', + updatedAt: Date.now() - index * 1000, + })); + return vi.fn(async (input: { workDir?: string; before?: string } = {}) => + input.before === undefined + ? { items: firstPage, nextCursor: 'ses-page1-49' } + : { + items: [{ id: 'ses-page2-0', workDir: '/tmp/proj-a', updatedAt: 0 }], + nextCursor: undefined, + }, + ); + } + + it('fetches the next session page when the picker scrolls to the fetched end', async () => { + const listSessionsPage = makePagedListSessionsPage(); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(listSessionsPage).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', limit: 50 }); + expect(driver.state.sessions).toHaveLength(50); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + for (let i = 0; i < 49; i++) { + picker.handleInput('\u001B[B'); + } + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(51); + }); + + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + expect(driver.state.sessions.map((session) => session.id)).toContain('ses-page2-0'); + }); + + it('drains the remaining session pages in the background once a query is typed', async () => { + const listSessionsPage = makePagedListSessionsPage(); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(driver.state.sessions).toHaveLength(50); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('x'); + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(51); + }); + + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + }); + + it('continues the search drain after an in-flight scroll fetch settles', async () => { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + id: `ses-page1-${String(index).padStart(2, '0')}`, + workDir: '/tmp/proj-a', + updatedAt: Date.now() - index * 1000, + })); + let resolveScrollPage!: (page: { items: unknown[]; nextCursor?: string }) => void; + const listSessionsPage = vi.fn((input: { workDir?: string; before?: string } = {}) => { + if (input.before === undefined) { + return Promise.resolve({ items: firstPage, nextCursor: 'ses-page1-49' }); + } + if (input.before === 'ses-page1-49') { + // The scroll-triggered page fetch stays pending until the test resolves it. + return new Promise<{ items: unknown[]; nextCursor?: string }>((resolve) => { + resolveScrollPage = resolve; + }); + } + return Promise.resolve({ + items: [{ id: 'ses-page3-0', workDir: '/tmp/proj-a', updatedAt: 0 }], + nextCursor: undefined, + }); + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + // Reach the fetched end: the scroll-triggered fetch for page 2 starts. + for (let i = 0; i < 49; i++) { + picker.handleInput('\u001B[B'); + } + await vi.waitFor(() => { + expect(listSessionsPage).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + }); + + // Typing a query while that fetch is in flight must join it, not stop the + // drain: the remaining pages arrive after the in-flight one settles. + picker.handleInput('x'); + resolveScrollPage({ + items: [{ id: 'ses-page2-0', workDir: '/tmp/proj-a', updatedAt: 1 }], + nextCursor: 'ses-page2-0', + }); + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(52); + }); + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page2-0', + }); + }); + it('clears the sessions picker search query when toggling scope with Ctrl+A', async () => { const currentWorkDirSession = { id: 'ses-cwd', diff --git a/apps/kimi-code/test/tui/utils/searchable-list.test.ts b/apps/kimi-code/test/tui/utils/searchable-list.test.ts index 170b8993a46..698d1a60496 100644 --- a/apps/kimi-code/test/tui/utils/searchable-list.test.ts +++ b/apps/kimi-code/test/tui/utils/searchable-list.test.ts @@ -97,4 +97,24 @@ describe('SearchableList', () => { expect(search.handleKey(BACKSPACE)).toBe(true); expect(search.view().query).toBe(''); }); + + it('setItems replaces the items, keeps the query, and clamps the cursor', () => { + const list = make({ searchable: true }); + for (const ch of 'zz') list.handleKey(ch); + list.setItems([...ITEMS, 'item10']); + // The active query survives an items swap and still filters. + expect(list.view().query).toBe('zz'); + expect(list.view().items).toHaveLength(0); + + expect(list.clearQuery()).toBe(true); + for (let i = 0; i < 20; i++) list.moveDown(); + expect(list.view().selectedIndex).toBe(10); + + // Shrinking the set clamps the cursor into the new range. + list.setItems(['item00']); + const v = list.view(); + expect(v.items).toEqual(['item00']); + expect(v.selectedIndex).toBe(0); + expect(list.selected()).toBe('item00'); + }); }); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index ae534384931..4ab32498a77 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -35,6 +35,7 @@ import type { ResumeSessionInput, ReloadSessionInput, SessionSummary, + SessionSummaryPage, SkillSummary, TelemetryClient, TelemetryContextPatch, @@ -258,6 +259,15 @@ export class KimiHarness { return this.rpc.listSessions(options); } + /** + * One keyset page of the session listing (`limit` / `before` in + * `ListSessionsOptions`). Paged on the v2 engine; the v1 engine serves the + * whole filtered set as a single terminal page. + */ + async listSessionsPage(options: ListSessionsOptions = {}): Promise { + return this.rpc.listSessionsPage(options); + } + /** Skills visible to a new session in `workDir`, without creating that session. */ async listWorkspaceSkills(workDir: string): Promise { return this.rpc.listWorkspaceSkills(workDir); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 270c9d1d729..b78f77c6b8d 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -59,6 +59,7 @@ import type { ResumeSessionInput, ResumedSessionSummary, SessionSummary, + SessionSummaryPage, SkillSummary, PluginCommandDef, Unsubscribe, @@ -224,6 +225,17 @@ export abstract class SDKRpcClientBase { return rpc.listSessions(input); } + /** + * One keyset page of the session listing (`limit` / `before` in + * `ListSessionsOptions`). The base implementation serves the whole filtered + * set as a single terminal page — the v1 engine has no paged listing; + * `SDKRpcClientV2` overrides this with real index paging. + */ + async listSessionsPage(input: ListSessionsOptions = {}): Promise { + const items = await this.listSessions(input); + return { items, nextCursor: undefined }; + } + async listWorkspaceSkills(workDir: string): Promise { const rpc = await this.getRpc(); return rpc.listWorkspaceSkills({ workDir }); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index e95bcee56fd..f667d662c9a 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -233,6 +233,7 @@ import { type Scope, type SecondaryModelConfig, type ServicesAccessor, + type SessionSummary as V2SessionSummary, } from '@moonshot-ai/agent-core-v2'; import type { AgentHandle, Klient } from '@moonshot-ai/klient'; import { createKlient } from '@moonshot-ai/klient/memory'; @@ -299,6 +300,7 @@ import type { SessionPlan, SessionStatus, SessionSummary, + SessionSummaryPage, SessionUsage, SkillSummary, TelemetryClient, @@ -990,50 +992,92 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } override async listSessions(input: ListSessionsOptions = {}): Promise { + // Full-set semantics: drain keyset pages until the listing is exhausted + // (an unpaged query currently answers in one page, but a backend may cap + // it — never silently truncate the unpaged contract). + const all: SessionSummary[] = []; + let before: string | undefined; + for (;;) { + const page = await this.listSessionsPage({ + workDir: input.workDir, + sessionId: input.sessionId, + before, + }); + all.push(...page.items); + if (page.nextCursor === undefined) return all; + before = page.nextCursor; + } + } + + override async listSessionsPage(input: ListSessionsOptions = {}): Promise { // v1 rejects an empty workDir and bucket-filters by the normalized path; // the v2 index filters by workspace-id set instead. const workspaceIds = input.workDir === undefined ? undefined : await this.workspaceIdsFor(normalizeRequiredWorkDir('listSessions', input.workDir)); - const page = await this.klient.global.sessions.list({ - workspaceIds, - sessionId: input.sessionId, - }); - const bootstrapService = this.engineAccessor.get(IBootstrapService); const workspacesById = new Map( (await this.klient.global.workspaces.list()).map((workspace) => [workspace.id, workspace]), ); - const summaries: SessionSummary[] = []; - for (const item of page.items) { - const workDir = item.cwd ?? workspacesById.get(item.workspaceId)?.root; - // A session whose workDir is unrecoverable (corrupt metadata, deleted - // workspace) cannot be resumed on either engine; v1's store never lists - // one in the first place, so drop it here too. - if (workDir === undefined) continue; - // A live session reports its own outcome; the index may still carry a - // stale one while the mirror's clear is queued (a fresh turn just - // started after a failure). - const liveHandle = getLiveSessionById(this.engineAccessor, item.id); - const effectiveItem = - liveHandle === undefined - ? item - : { - ...item, - lastTurnReason: liveHandle.accessor.get(ISessionActivityView).state().lastTurnReason, - }; - summaries.push( - v2SummaryToSessionSummary(effectiveItem, { - workDir, - sessionDir: sessionDirOf( - bootstrapService.homeDir, - workspacePersistenceScope(bootstrapService.scope('sessions'), item.workspaceId), - item.id, - ), - }), - ); + const collected: SessionSummary[] = []; + let before = input.before; + // Entries dropped by the mapping (unrecoverable workDir) shrink the page; + // keep pulling keyset pages until the requested size is filled so callers + // never see a short or empty page that still carries a cursor. + for (;;) { + const remaining = input.limit === undefined ? undefined : input.limit - collected.length; + if (remaining !== undefined && remaining <= 0) break; + const page = await this.klient.global.sessions.list({ + workspaceIds, + sessionId: input.sessionId, + limit: remaining, + before, + }); + if (page.items.length === 0) return { items: collected, nextCursor: undefined }; + for (const item of page.items) { + const summary = this.mapIndexSummary(item, workspacesById); + if (summary !== undefined) collected.push(summary); + } + if (page.nextCursor === undefined) return { items: collected, nextCursor: undefined }; + before = page.nextCursor; + if (input.limit === undefined) return { items: collected, nextCursor: before }; } - return summaries; + return { items: collected, nextCursor: before }; + } + + /** + * Map one v2 index summary to the v1 wire shape, resolving the filesystem + * facts the index does not carry. Returns `undefined` when the session's + * workDir is unrecoverable (corrupt metadata, deleted workspace): such a + * session cannot be resumed on either engine, and v1's store never lists + * one in the first place. + */ + private mapIndexSummary( + item: V2SessionSummary, + workspacesById: ReadonlyMap, + ): SessionSummary | undefined { + const workDir = item.cwd ?? workspacesById.get(item.workspaceId)?.root; + if (workDir === undefined) return undefined; + // A live session reports its own outcome; the index may still carry a + // stale one while the mirror's clear is queued (a fresh turn just + // started after a failure). + const liveHandle = getLiveSessionById(this.engineAccessor, item.id); + const effectiveItem = + liveHandle === undefined + ? item + : { + ...item, + lastTurnReason: liveHandle.accessor.get(ISessionActivityView).state().lastTurnReason, + }; + const bootstrapService = this.engineAccessor.get(IBootstrapService); + return v2SummaryToSessionSummary(effectiveItem, { + workDir, + sessionDir: sessionDirOf( + bootstrapService.homeDir, + workspacePersistenceScope(bootstrapService.scope('sessions'), item.workspaceId), + item.id, + ), + }); } /** diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 8ce05461d45..8e89c4246fb 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -218,6 +218,20 @@ export interface ExportSessionResult { export interface ListSessionsOptions { readonly workDir?: string; readonly sessionId?: string; + /** + * Maximum number of summaries in one page. Only consulted by + * `listSessionsPage`; plain `listSessions` always returns the whole + * filtered set. + */ + readonly limit?: number; + /** Keyset cursor: return the page strictly older than this session id. */ + readonly before?: string; +} + +export interface SessionSummaryPage { + readonly items: readonly SessionSummary[]; + /** Pass as `before` for the next older page; absent when the listing is exhausted. */ + readonly nextCursor?: string; } export interface GetConfigOptions { diff --git a/packages/node-sdk/test/list-sessions.test.ts b/packages/node-sdk/test/list-sessions.test.ts index 8ee0e60bd87..08afb9802b8 100644 --- a/packages/node-sdk/test/list-sessions.test.ts +++ b/packages/node-sdk/test/list-sessions.test.ts @@ -15,6 +15,7 @@ import { drainQueryStoreDisposals, drainSessionIndexMirror, ISessionIndex, + ISessionIndexMirror, } from '@moonshot-ai/agent-core-v2'; import { createKimiHarness, SDKRpcClientV2 } from '#/index'; @@ -435,6 +436,138 @@ describe('KimiHarness.listSessions', () => { await harness.close(); } }); + + it('serves the full set as one terminal page on the v1 engine', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await harness.createSession({ id: 'ses_v1_page_a', workDir }); + await harness.createSession({ id: 'ses_v1_page_b', workDir }); + + // The v1 engine has no paged listing: `limit` is ignored and the whole + // filtered set comes back as a single page without a cursor. + const page = await harness.listSessionsPage({ workDir, limit: 1 }); + expect(page.items.map((item) => item.id).toSorted()).toEqual([ + 'ses_v1_page_a', + 'ses_v1_page_b', + ]); + expect(page.nextCursor).toBeUndefined(); + } finally { + await harness.close(); + } + }); +}); + +describe('SDKRpcClientV2.listSessionsPage', () => { + it('pages through the listing with keyset cursors (read model off)', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL', '0'); + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + + try { + for (let i = 0; i < 5; i += 1) { + const created = await client.createSession({ id: `ses_page_${i}`, workDir }); + await client.closeSession({ sessionId: created.id }); + } + + const page1 = await client.listSessionsPage({ workDir, limit: 2 }); + expect(page1.items).toHaveLength(2); + expect(page1.nextCursor).toBe(page1.items.at(-1)?.id); + + const page2 = await client.listSessionsPage({ workDir, limit: 2, before: page1.nextCursor }); + expect(page2.items).toHaveLength(2); + expect(page2.nextCursor).toBe(page2.items.at(-1)?.id); + + const page3 = await client.listSessionsPage({ workDir, limit: 2, before: page2.nextCursor }); + expect(page3.items).toHaveLength(1); + expect(page3.nextCursor).toBeUndefined(); + + const pagedIds = [...page1.items, ...page2.items, ...page3.items].map((item) => item.id); + expect(new Set(pagedIds)).toEqual( + new Set([0, 1, 2, 3, 4].map((i) => `ses_page_${String(i)}`)), + ); + // Draining pages yields exactly the unpaged listing, in the same order. + const full = await client.listSessions({ workDir }); + expect(pagedIds).toEqual(full.map((item) => item.id)); + } finally { + await client.close(); + vi.unstubAllEnvs(); + } + }); + + it('answers an empty terminal page for an unknown cursor', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL', '0'); + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + + try { + const created = await client.createSession({ id: 'ses_cursor_probe', workDir }); + await client.closeSession({ sessionId: created.id }); + + await expect( + client.listSessionsPage({ workDir, before: 'ses_unknown' }), + ).resolves.toEqual({ items: [], nextCursor: undefined }); + } finally { + await client.close(); + vi.unstubAllEnvs(); + } + }); + + it('drains follow-up pages when the mapping drops entries (read model on)', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL', '1'); + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + + try { + for (let i = 0; i < 3; i += 1) { + const created = await client.createSession({ id: `ses_drain_${i}`, workDir }); + await client.closeSession({ sessionId: created.id }); + } + const index = client.engineAccessor.get(ISessionIndex); + await index.prepare(); + // A summary whose workDir can no longer be resolved (unknown workspace, + // no cwd) is dropped by the mapping; the page must still fill. + client.engineAccessor.get(ISessionIndexMirror).record({ + id: 'ses_ghost', + workspaceId: 'ws_missing', + createdAt: 1, + updatedAt: Date.now() + 60_000, + archived: false, + }); + await drainSessionIndexMirror(); + + const page1 = await client.listSessionsPage({ limit: 2 }); + expect(page1.items).toHaveLength(2); + expect(page1.items.some((item) => item.id === 'ses_ghost')).toBe(false); + expect(page1.nextCursor).toBeDefined(); + + const page2 = await client.listSessionsPage({ limit: 2, before: page1.nextCursor }); + expect(page2.items).toHaveLength(1); + expect(page2.items[0]?.id).not.toBe('ses_ghost'); + expect(page2.nextCursor).toBeUndefined(); + + const ids = [...page1.items, ...page2.items].map((item) => item.id).toSorted(); + expect(ids).toEqual(['ses_drain_0', 'ses_drain_1', 'ses_drain_2']); + } finally { + await client.close(); + // Dispose fired the mirror/query-store async closes; await them before + // the shared afterEach removes the temp home. + await drainSessionIndexMirror(); + await drainQueryStoreDisposals(); + vi.unstubAllEnvs(); + } + }); }); describe('SDKRpcClientV2 search-index separation', () => {