From e8d91f2bd469947daeeaa92fea52550e7e6763a1 Mon Sep 17 00:00:00 2001 From: Thomas Mustier Date: Sat, 10 Jan 2026 20:33:05 +0000 Subject: [PATCH] feat(coding-agent): add resume scope toggle refactor(coding-agent): refine session listing helpers --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/cli/session-picker.ts | 8 +- .../coding-agent/src/core/session-manager.ts | 172 +++++++++++------- packages/coding-agent/src/main.ts | 7 +- .../components/session-selector.ts | 130 ++++++++++--- .../src/modes/interactive/interactive-mode.ts | 6 +- 6 files changed, 219 insertions(+), 106 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b612023c..24d33994 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Added - +- `/resume` selector now toggles between current-folder and all sessions with Tab, showing the session cwd in the All view. - `/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/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index f1ff837b..07f67d65 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -7,13 +7,17 @@ import type { SessionInfo } from "../core/session-manager.js"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; /** Show TUI session selector and return selected session path or null if cancelled */ -export async function selectSession(sessions: SessionInfo[]): Promise { +export async function selectSession( + currentSessions: SessionInfo[], + allSessions: SessionInfo[], +): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); let resolved = false; const selector = new SessionSelectorComponent( - sessions, + currentSessions, + allSessions, (path: string) => { if (!resolved) { resolved = true; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index c3f7e0b0..22231704 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -14,7 +14,7 @@ import { writeFileSync, } from "fs"; import { join, resolve } from "path"; -import { getAgentDir as getDefaultAgentDir } from "../config.js"; +import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; import { type BashExecutionMessage, type CustomMessage, @@ -156,6 +156,7 @@ export interface SessionContext { export interface SessionInfo { path: string; id: string; + cwd?: string; created: Date; modified: Date; messageCount: number; @@ -470,6 +471,92 @@ export function findMostRecentSession(sessionDir: string): string | null { } } +function isMessageWithContent(message: AgentMessage): message is Message { + return typeof (message as Message).role === "string" && "content" in message; +} + +function extractTextContent(message: Message): string { + const content = message.content; + if (typeof content === "string") { + return content; + } + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join(" "); +} + +function buildSessionInfo(filePath: string): SessionInfo | null { + const entries = loadEntriesFromFile(filePath); + if (entries.length === 0) return null; + + 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; + } + } + + 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[] { + const sessions: SessionInfo[] = []; + if (!existsSync(dir)) { + return sessions; + } + + try { + const files = readdirSync(dir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => join(dir, f)); + + for (const file of files) { + try { + const info = buildSessionInfo(file); + if (info) { + sessions.push(info); + } + } catch { + // Skip files that can't be read + } + } + } catch { + // Return empty list on error + } + + return sessions; +} + /** * Manages conversation sessions as append-only trees stored in JSONL files. * @@ -1063,82 +1150,29 @@ export class SessionManager { */ static list(cwd: string, sessionDir?: string): SessionInfo[] { const dir = sessionDir ?? getDefaultSessionDir(cwd); + const sessions = listSessionsFromDir(dir); + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } + + static listAll(): SessionInfo[] { const sessions: SessionInfo[] = []; + const sessionsDir = getSessionsDir(); try { - const files = readdirSync(dir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(dir, f)); - - for (const file of files) { - try { - const content = readFileSync(file, "utf8"); - const lines = content.trim().split("\n"); - if (lines.length === 0) continue; - - // Check first line for valid session header - let header: { type: string; id: string; timestamp: string } | null = null; - try { - const first = JSON.parse(lines[0]); - if (first.type === "session" && first.id) { - header = first; - } - } catch { - // Not valid JSON - } - if (!header) continue; - - const stats = statSync(file); - let messageCount = 0; - let firstMessage = ""; - const allMessages: string[] = []; - - for (let i = 1; i < lines.length; i++) { - try { - const entry = JSON.parse(lines[i]); - - if (entry.type === "message") { - messageCount++; - - if (entry.message.role === "user" || entry.message.role === "assistant") { - const textContent = entry.message.content - .filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join(" "); - - if (textContent) { - allMessages.push(textContent); - - if (!firstMessage && entry.message.role === "user") { - firstMessage = textContent; - } - } - } - } - } catch { - // Skip malformed lines - } - } - - sessions.push({ - path: file, - id: header.id, - created: new Date(header.timestamp), - modified: stats.mtime, - messageCount, - firstMessage: firstMessage || "(no messages)", - allMessagesText: allMessages.join(" "), - }); - } catch { - // Skip files that can't be read - } + if (!existsSync(sessionsDir)) { + return sessions; + } + const entries = readdirSync(sessionsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + sessions.push(...listSessionsFromDir(join(sessionsDir, entry.name))); } - - sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); } catch { // Return empty list on error } + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 1b1d12e9..6c0db16f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -317,13 +317,14 @@ export async function main(args: string[]) { // Initialize keybindings so session picker respects user config KeybindingsManager.create(); - const sessions = SessionManager.list(cwd, parsed.sessionDir); + const currentSessions = SessionManager.list(cwd, parsed.sessionDir); + const allSessions = SessionManager.listAll(); time("SessionManager.list"); - if (sessions.length === 0) { + if (currentSessions.length === 0 && allSessions.length === 0) { console.log(chalk.dim("No sessions found")); return; } - const selectedPath = await selectSession(sessions); + const selectedPath = await selectSession(currentSessions, allSessions); 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 343c7379..801328fd 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -1,3 +1,4 @@ +import * as os from "node:os"; import { type Component, Container, @@ -5,13 +6,68 @@ import { getEditorKeybindings, Input, Spacer, - Text, truncateToWidth, + visibleWidth, } from "@mariozechner/pi-tui"; import type { SessionInfo } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +type SessionScope = "current" | "all"; + +function shortenPath(path: string): string { + const home = os.homedir(); + if (!path) return path; + if (path.startsWith(home)) { + return `~${path.slice(home.length)}`; + } + return path; +} + +function formatSessionDate(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`; + if (diffDays === 1) return "1 day ago"; + if (diffDays < 7) return `${diffDays} days ago`; + + return date.toLocaleDateString(); +} + +class SessionSelectorHeader implements Component { + private scope: SessionScope; + + constructor(scope: SessionScope) { + this.scope = scope; + } + + setScope(scope: SessionScope): void { + this.scope = scope; + } + + 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")}`; + 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}`]; + } +} + /** * Custom session list component with multi-line items and search */ @@ -20,15 +76,18 @@ class SessionList implements Component { private filteredSessions: SessionInfo[] = []; private selectedIndex: number = 0; private searchInput: Input; + private showCwd = false; public onSelect?: (sessionPath: string) => void; public onCancel?: () => void; public onExit: () => void = () => {}; + public onToggleScope?: () => void; private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) - constructor(sessions: SessionInfo[]) { + constructor(sessions: SessionInfo[], showCwd: boolean) { this.allSessions = sessions; this.filteredSessions = sessions; this.searchInput = new Input(); + this.showCwd = showCwd; // Handle Enter in search input - select current item this.searchInput.onSubmit = () => { @@ -41,18 +100,22 @@ class SessionList implements Component { }; } + setSessions(sessions: SessionInfo[], showCwd: boolean): void { + this.allSessions = sessions; + this.showCwd = showCwd; + this.filterSessions(this.searchInput.getValue()); + } + private filterSessions(query: string): void { this.filteredSessions = fuzzyFilter( this.allSessions, query, - (session) => `${session.id} ${session.allMessagesText}`, + (session) => `${session.id} ${session.allMessagesText} ${session.cwd}`, ); this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } - invalidate(): void { - // No cached state to invalidate currently - } + invalidate(): void {} render(width: number): string[] { const lines: string[] = []; @@ -66,23 +129,6 @@ class SessionList implements Component { return lines; } - // Format dates - const formatDate = (date: Date): string => { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`; - if (diffDays === 1) return "1 day ago"; - if (diffDays < 7) return `${diffDays} days ago`; - - return date.toLocaleDateString(); - }; - // Calculate visible range with scrolling const startIndex = Math.max( 0, @@ -105,9 +151,13 @@ class SessionList implements Component { const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); // Second line: metadata (dimmed) - also truncate for safety - const modified = formatDate(session.modified); + const modified = formatSessionDate(session.modified); const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; - const metadata = ` ${modified} · ${msgCount}`; + const metadataParts = [modified, msgCount]; + if (this.showCwd && session.cwd) { + metadataParts.push(shortenPath(session.cwd)); + } + const metadata = ` ${metadataParts.join(" · ")}`; const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, "")); lines.push(messageLine); @@ -127,6 +177,12 @@ class SessionList implements Component { handleInput(keyData: string): void { const kb = getEditorKeybindings(); + if (kb.matches(keyData, "tab")) { + if (this.onToggleScope) { + this.onToggleScope(); + } + return; + } // Up arrow if (kb.matches(keyData, "selectUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); @@ -161,27 +217,36 @@ class SessionList implements Component { */ export class SessionSelectorComponent extends Container { private sessionList: SessionList; + private header: SessionSelectorHeader; + private scope: SessionScope = "current"; + private currentSessions: SessionInfo[]; + private allSessions: SessionInfo[]; constructor( - sessions: SessionInfo[], + currentSessions: SessionInfo[], + allSessions: SessionInfo[], onSelect: (sessionPath: string) => void, onCancel: () => void, onExit: () => void, ) { super(); + this.currentSessions = currentSessions; + this.allSessions = allSessions; + this.header = new SessionSelectorHeader(this.scope); // Add header this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold("Resume Session"), 1, 0)); + this.addChild(this.header); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); // Create session list - this.sessionList = new SessionList(sessions); + this.sessionList = new SessionList(this.currentSessions, this.scope === "all"); this.sessionList.onSelect = onSelect; this.sessionList.onCancel = onCancel; this.sessionList.onExit = onExit; + this.sessionList.onToggleScope = () => this.toggleScope(); this.addChild(this.sessionList); @@ -190,11 +255,18 @@ export class SessionSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Auto-cancel if no sessions - if (sessions.length === 0) { + if (currentSessions.length === 0 && allSessions.length === 0) { setTimeout(() => onCancel(), 100); } } + 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); + } + getSessionList(): SessionList { return this.sessionList; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index cd9e49c2..666a3682 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2876,9 +2876,11 @@ export class InteractiveMode { private showSessionSelector(): void { this.showSelector((done) => { - const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir()); + const currentSessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir()); + const allSessions = SessionManager.listAll(); const selector = new SessionSelectorComponent( - sessions, + currentSessions, + allSessions, async (sessionPath) => { done(); await this.handleResumeSession(sessionPath);