diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 24d33994..744d9510 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,8 +2,14 @@ ## [Unreleased] +### Breaking Changes +- `SessionManager.list()` and `SessionManager.listAll()` are now async, returning `Promise`. Callers must await them. ([#620](https://github.com/badlogic/pi-mono/pull/620) by [@tmustier](https://github.com/tmustier)) + ### Added -- `/resume` selector now toggles between current-folder and all sessions with Tab, showing the session cwd in the All view. +- `/resume` selector now toggles between current-folder and all sessions with Tab, showing the session cwd in the All view and loading progress. ([#620](https://github.com/badlogic/pi-mono/pull/620) by [@tmustier](https://github.com/tmustier)) +- `SessionManager.list()` and `SessionManager.listAll()` accept optional `onProgress` callback for progress updates +- `SessionInfo.cwd` field containing the session's working directory (empty string for old sessions) +- `SessionListProgress` type export for progress callbacks - `/models` command to enable/disable models for Ctrl+P cycling. Changes persist to `enabledModels` in settings.json and take effect immediately. ([#626](https://github.com/badlogic/pi-mono/pull/626) by [@CarlosGtrz](https://github.com/CarlosGtrz)) - `model_select` extension hook fires when model changes via `/model`, model cycling, or session restore with `source` field and `previousModel` ([#628](https://github.com/badlogic/pi-mono/pull/628) by [@marckrenn](https://github.com/marckrenn)) - `ctx.ui.setWorkingMessage()` extension API to customize the "Working..." message during streaming ([#625](https://github.com/badlogic/pi-mono/pull/625) by [@nicobailon](https://github.com/nicobailon)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 2a022863..91cea529 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -459,7 +459,7 @@ Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory. pi --continue # Continue most recent session pi -c # Short form -pi --resume # Browse and select from past sessions +pi --resume # Browse and select from past sessions (Tab to toggle Current Folder / All) pi -r # Short form pi --no-session # Ephemeral mode (don't save) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 1ee24d45..2228d72d 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -636,12 +636,17 @@ const { session } = await createAgentSession({ sessionManager: SessionManager.open("/path/to/session.jsonl"), }); -// List available sessions -const sessions = SessionManager.list(process.cwd()); +// List available sessions (async with optional progress callback) +const sessions = await SessionManager.list(process.cwd()); for (const info of sessions) { - console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages)`); + console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages, cwd: ${info.cwd})`); } +// List all sessions across all projects +const allSessions = await SessionManager.listAll((loaded, total) => { + console.log(`Loading ${loaded}/${total}...`); +}); + // Custom session directory (no cwd encoding) const customDir = "/path/to/my-sessions"; const { session } = await createAgentSession({ diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts index f1bbd047..9c32016d 100644 --- a/packages/coding-agent/examples/sdk/11-sessions.ts +++ b/packages/coding-agent/examples/sdk/11-sessions.ts @@ -26,7 +26,7 @@ if (modelFallbackMessage) console.log("Note:", modelFallbackMessage); console.log("Continued session:", continued.sessionFile); // List and open specific session -const sessions = SessionManager.list(process.cwd()); +const sessions = await SessionManager.list(process.cwd()); console.log(`\nFound ${sessions.length} sessions:`); for (const info of sessions.slice(0, 3)) { console.log(` ${info.id.slice(0, 8)}... - "${info.firstMessage.slice(0, 30)}..."`); diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 07f67d65..3ca22355 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -3,21 +3,23 @@ */ import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; -import type { SessionInfo } from "../core/session-manager.js"; +import type { SessionInfo, SessionListProgress } from "../core/session-manager.js"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; +type SessionsLoader = (onProgress?: SessionListProgress) => Promise; + /** Show TUI session selector and return selected session path or null if cancelled */ export async function selectSession( - currentSessions: SessionInfo[], - allSessions: SessionInfo[], + currentSessionsLoader: SessionsLoader, + allSessionsLoader: SessionsLoader, ): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); let resolved = false; const selector = new SessionSelectorComponent( - currentSessions, - allSessions, + currentSessionsLoader, + allSessionsLoader, (path: string) => { if (!resolved) { resolved = true; @@ -36,6 +38,7 @@ export async function selectSession( ui.stop(); process.exit(0); }, + () => ui.requestRender(), ); ui.addChild(selector); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 22231704..a35ff8c9 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -13,6 +13,7 @@ import { statSync, writeFileSync, } from "fs"; +import { readdir, readFile, stat } from "fs/promises"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; import { @@ -156,7 +157,8 @@ export interface SessionContext { export interface SessionInfo { path: string; id: string; - cwd?: string; + /** Working directory where the session was started. Empty string for old sessions. */ + cwd: string; created: Date; modified: Date; messageCount: number; @@ -486,68 +488,94 @@ function extractTextContent(message: Message): string { .join(" "); } -function buildSessionInfo(filePath: string): SessionInfo | null { - const entries = loadEntriesFromFile(filePath); - if (entries.length === 0) return null; +async function buildSessionInfo(filePath: string): Promise { + try { + const content = await readFile(filePath, "utf8"); + const entries: FileEntry[] = []; + const lines = content.trim().split("\n"); - const header = entries[0]; - if (header.type !== "session") return null; - - const stats = statSync(filePath); - let messageCount = 0; - let firstMessage = ""; - const allMessages: string[] = []; - - for (const entry of entries) { - if (entry.type !== "message") continue; - messageCount++; - - const message = entry.message; - if (!isMessageWithContent(message)) continue; - if (message.role !== "user" && message.role !== "assistant") continue; - - const textContent = extractTextContent(message); - if (!textContent) continue; - - allMessages.push(textContent); - if (!firstMessage && message.role === "user") { - firstMessage = textContent; + for (const line of lines) { + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line) as FileEntry); + } catch { + // Skip malformed lines + } } + + if (entries.length === 0) return null; + const header = entries[0]; + if (header.type !== "session") return null; + + const stats = await stat(filePath); + let messageCount = 0; + let firstMessage = ""; + const allMessages: string[] = []; + + for (const entry of entries) { + if (entry.type !== "message") continue; + messageCount++; + + const message = (entry as SessionMessageEntry).message; + if (!isMessageWithContent(message)) continue; + if (message.role !== "user" && message.role !== "assistant") continue; + + const textContent = extractTextContent(message); + if (!textContent) continue; + + allMessages.push(textContent); + if (!firstMessage && message.role === "user") { + firstMessage = textContent; + } + } + + const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : ""; + + return { + path: filePath, + id: (header as SessionHeader).id, + cwd, + created: new Date((header as SessionHeader).timestamp), + modified: stats.mtime, + messageCount, + firstMessage: firstMessage || "(no messages)", + allMessagesText: allMessages.join(" "), + }; + } catch { + return null; } - - const cwd = typeof header.cwd === "string" ? header.cwd : ""; - - return { - path: filePath, - id: header.id, - cwd, - created: new Date(header.timestamp), - modified: stats.mtime, - messageCount, - firstMessage: firstMessage || "(no messages)", - allMessagesText: allMessages.join(" "), - }; } -function listSessionsFromDir(dir: string): SessionInfo[] { +export type SessionListProgress = (loaded: number, total: number) => void; + +async function listSessionsFromDir( + dir: string, + onProgress?: SessionListProgress, + progressOffset = 0, + progressTotal?: number, +): Promise { const sessions: SessionInfo[] = []; if (!existsSync(dir)) { return sessions; } try { - const files = readdirSync(dir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(dir, f)); + const dirEntries = await readdir(dir); + const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f)); + const total = progressTotal ?? files.length; - for (const file of files) { - try { - const info = buildSessionInfo(file); - if (info) { - sessions.push(info); - } - } catch { - // Skip files that can't be read + let loaded = 0; + const results = await Promise.all( + files.map(async (file) => { + const info = await buildSessionInfo(file); + loaded++; + onProgress?.(progressOffset + loaded, total); + return info; + }), + ); + for (const info of results) { + if (info) { + sessions.push(info); } } } catch { @@ -1144,35 +1172,69 @@ export class SessionManager { } /** - * List all sessions. + * List all sessions for a directory. * @param cwd Working directory (used to compute default session directory) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). + * @param onProgress Optional callback for progress updates (loaded, total) */ - static list(cwd: string, sessionDir?: string): SessionInfo[] { + static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { const dir = sessionDir ?? getDefaultSessionDir(cwd); - const sessions = listSessionsFromDir(dir); + const sessions = await listSessionsFromDir(dir, onProgress); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } - static listAll(): SessionInfo[] { - const sessions: SessionInfo[] = []; + /** + * List all sessions across all project directories. + * @param onProgress Optional callback for progress updates (loaded, total) + */ + static async listAll(onProgress?: SessionListProgress): Promise { const sessionsDir = getSessionsDir(); try { if (!existsSync(sessionsDir)) { - return sessions; + return []; } - const entries = readdirSync(sessionsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - sessions.push(...listSessionsFromDir(join(sessionsDir, entry.name))); - } - } catch { - // Return empty list on error - } + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name)); - sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); - return sessions; + // Count total files first for accurate progress + let totalFiles = 0; + const dirFiles: string[][] = []; + for (const dir of dirs) { + try { + const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")); + dirFiles.push(files.map((f) => join(dir, f))); + totalFiles += files.length; + } catch { + dirFiles.push([]); + } + } + + // Process all files with progress tracking + let loaded = 0; + const sessions: SessionInfo[] = []; + const allFiles = dirFiles.flat(); + + const results = await Promise.all( + allFiles.map(async (file) => { + const info = await buildSessionInfo(file); + loaded++; + onProgress?.(loaded, totalFiles); + return info; + }), + ); + + for (const info of results) { + if (info) { + sessions.push(info); + } + } + + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } catch { + return []; + } } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6c0db16f..ed080053 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -61,14 +61,14 @@ async function prepareInitialMessage( * Resolve a session argument to a file path. * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. */ -function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): string { +async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { // If it looks like a file path, use as-is if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { return sessionArg; } // Try to match as session ID (full or partial UUID) - const sessions = SessionManager.list(cwd, sessionDir); + const sessions = await SessionManager.list(cwd, sessionDir); const matches = sessions.filter((s) => s.id.startsWith(sessionArg)); if (matches.length >= 1) { @@ -79,12 +79,12 @@ function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string return sessionArg; } -function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined { +async function createSessionManager(parsed: Args, cwd: string): Promise { if (parsed.noSession) { return SessionManager.inMemory(); } if (parsed.session) { - const resolvedPath = resolveSessionPath(parsed.session, cwd, parsed.sessionDir); + const resolvedPath = await resolveSessionPath(parsed.session, cwd, parsed.sessionDir); return SessionManager.open(resolvedPath, parsed.sessionDir); } if (parsed.continue) { @@ -309,7 +309,7 @@ export async function main(args: string[]) { } // Create session manager based on CLI flags - let sessionManager = createSessionManager(parsed, cwd); + let sessionManager = await createSessionManager(parsed, cwd); time("createSessionManager"); // Handle --resume: show session picker @@ -317,14 +317,10 @@ export async function main(args: string[]) { // Initialize keybindings so session picker respects user config KeybindingsManager.create(); - const currentSessions = SessionManager.list(cwd, parsed.sessionDir); - const allSessions = SessionManager.listAll(); - time("SessionManager.list"); - if (currentSessions.length === 0 && allSessions.length === 0) { - console.log(chalk.dim("No sessions found")); - return; - } - const selectedPath = await selectSession(currentSessions, allSessions); + const selectedPath = await selectSession( + (onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress), + SessionManager.listAll, + ); time("selectSession"); if (!selectedPath) { console.log(chalk.dim("No session selected")); diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 801328fd..747c0b4a 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -9,7 +9,7 @@ import { truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui"; -import type { SessionInfo } from "../../../core/session-manager.js"; +import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -42,6 +42,8 @@ function formatSessionDate(date: Date): string { class SessionSelectorHeader implements Component { private scope: SessionScope; + private loading = false; + private loadProgress: { loaded: number; total: number } | null = null; constructor(scope: SessionScope) { this.scope = scope; @@ -51,20 +53,38 @@ class SessionSelectorHeader implements Component { this.scope = scope; } + setLoading(loading: boolean): void { + this.loading = loading; + if (!loading) { + this.loadProgress = null; + } + } + + setProgress(loaded: number, total: number): void { + this.loadProgress = { loaded, total }; + } + invalidate(): void {} render(width: number): string[] { const title = this.scope === "current" ? "Resume Session (Current Folder)" : "Resume Session (All)"; const leftText = theme.bold(title); - const scopeText = - this.scope === "current" - ? `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}` - : `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`; + let scopeText: string; + if (this.loading) { + const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "..."; + scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", `Loading ${progressText}`)}`; + } else { + scopeText = + this.scope === "current" + ? `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}` + : `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`; + } const rightText = truncateToWidth(scopeText, width, ""); const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1); const left = truncateToWidth(leftText, availableLeft, ""); const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText)); - return [`${left}${" ".repeat(spacing)}${rightText}`]; + const hint = theme.fg("muted", "Tab to toggle scope"); + return [`${left}${" ".repeat(spacing)}${rightText}`, hint]; } } @@ -212,6 +232,8 @@ class SessionList implements Component { } } +type SessionsLoader = (onProgress?: SessionListProgress) => Promise; + /** * Component that renders a session selector */ @@ -219,19 +241,26 @@ export class SessionSelectorComponent extends Container { private sessionList: SessionList; private header: SessionSelectorHeader; private scope: SessionScope = "current"; - private currentSessions: SessionInfo[]; - private allSessions: SessionInfo[]; + private currentSessions: SessionInfo[] | null = null; + private allSessions: SessionInfo[] | null = null; + private currentSessionsLoader: SessionsLoader; + private allSessionsLoader: SessionsLoader; + private onCancel: () => void; + private requestRender: () => void; constructor( - currentSessions: SessionInfo[], - allSessions: SessionInfo[], + currentSessionsLoader: SessionsLoader, + allSessionsLoader: SessionsLoader, onSelect: (sessionPath: string) => void, onCancel: () => void, onExit: () => void, + requestRender: () => void, ) { super(); - this.currentSessions = currentSessions; - this.allSessions = allSessions; + this.currentSessionsLoader = currentSessionsLoader; + this.allSessionsLoader = allSessionsLoader; + this.onCancel = onCancel; + this.requestRender = requestRender; this.header = new SessionSelectorHeader(this.scope); // Add header @@ -241,8 +270,8 @@ export class SessionSelectorComponent extends Container { this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - // Create session list - this.sessionList = new SessionList(this.currentSessions, this.scope === "all"); + // Create session list (starts empty, will be populated after load) + this.sessionList = new SessionList([], false); this.sessionList.onSelect = onSelect; this.sessionList.onCancel = onCancel; this.sessionList.onExit = onExit; @@ -254,17 +283,62 @@ export class SessionSelectorComponent extends Container { this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - // Auto-cancel if no sessions - if (currentSessions.length === 0 && allSessions.length === 0) { - setTimeout(() => onCancel(), 100); - } + // Start loading current sessions immediately + this.loadCurrentSessions(); + } + + private loadCurrentSessions(): void { + this.header.setLoading(true); + this.requestRender(); + this.currentSessionsLoader((loaded, total) => { + this.header.setProgress(loaded, total); + this.requestRender(); + }).then((sessions) => { + this.currentSessions = sessions; + this.header.setLoading(false); + this.sessionList.setSessions(sessions, false); + this.requestRender(); + // If no sessions found, cancel + if (sessions.length === 0) { + this.onCancel(); + } + }); } private toggleScope(): void { - this.scope = this.scope === "current" ? "all" : "current"; - const sessions = this.scope === "current" ? this.currentSessions : this.allSessions; - this.sessionList.setSessions(sessions, this.scope === "all"); - this.header.setScope(this.scope); + if (this.scope === "current") { + // Switching to "all" - load if not already loaded + if (this.allSessions === null) { + this.header.setLoading(true); + this.header.setScope("all"); + this.sessionList.setSessions([], true); // Clear list while loading + this.requestRender(); + // Load asynchronously with progress updates + this.allSessionsLoader((loaded, total) => { + this.header.setProgress(loaded, total); + this.requestRender(); + }).then((sessions) => { + this.allSessions = sessions; + this.header.setLoading(false); + this.scope = "all"; + this.sessionList.setSessions(this.allSessions, true); + this.requestRender(); + // If no sessions in All scope either, cancel + if (this.allSessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) { + this.onCancel(); + } + }); + } else { + this.scope = "all"; + this.sessionList.setSessions(this.allSessions, true); + this.header.setScope(this.scope); + } + } else { + // Switching back to "current" + this.scope = "current"; + this.sessionList.setSessions(this.currentSessions ?? [], false); + this.header.setScope(this.scope); + } } getSessionList(): SessionList { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 666a3682..dfea3ba3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2876,11 +2876,10 @@ export class InteractiveMode { private showSessionSelector(): void { this.showSelector((done) => { - const currentSessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir()); - const allSessions = SessionManager.listAll(); const selector = new SessionSelectorComponent( - currentSessions, - allSessions, + (onProgress) => + SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), + SessionManager.listAll, async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); @@ -2892,6 +2891,7 @@ export class InteractiveMode { () => { void this.shutdown(); }, + () => this.ui.requestRender(), ); return { component: selector, focus: selector.getSessionList() }; });