Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sdk-list-sessions-page.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/session-picker-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Page the /sessions picker list so it opens fast with large session counts.
72 changes: 67 additions & 5 deletions apps/kimi-code/src/tui/components/dialogs/session-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionRow>;

focused = false;
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -152,13 +186,27 @@ 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;
}

const loadedCount = Math.min(view.items.length, this.visibleCount);
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 {
Expand Down Expand Up @@ -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)));
}

Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/constant/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
152 changes: 122 additions & 30 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1972,13 +1975,16 @@ export class KimiTUI {
async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise<void> {
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(),
);
Expand All @@ -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<boolean> {
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<boolean> {
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<void> {
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;
Expand Down Expand Up @@ -3233,6 +3314,8 @@ export class KimiTUI {
forwardEditorExit: false,
};
private sessionPickerScopeRequestToken = 0;
private sessionPickerComponent: SessionPickerComponent | undefined;
private sessionsPageFetchInFlight: Promise<boolean> | undefined;

async showSessionPicker(): Promise<void> {
await this.openSessionPicker({
Expand Down Expand Up @@ -3304,6 +3387,7 @@ export class KimiTUI {

hideSessionPicker(): void {
this.sessionPickerScopeRequestToken += 1;
this.sessionPickerComponent = undefined;
this.editorKeyboard.clearPendingExit();
this.state.activeDialog = null;
this.restoreEditor();
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/tui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion apps/kimi-code/src/tui/utils/searchable-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface SearchableListView<T> {
}

export class SearchableList<T> {
private readonly items: readonly T[];
private items: readonly T[];
private readonly toSearchText: (item: T) => string;
private readonly pageSize: number;
private readonly searchable: boolean;
Expand All @@ -53,6 +53,15 @@ export class SearchableList<T> {
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);
Expand Down
Loading
Loading