From 458702b3a7ef3e42452dadc7360444e9400518ae Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 12 Nov 2025 09:17:04 +0100 Subject: [PATCH] Add --resume flag with session selector - New SessionSelectorComponent to browse and select sessions - Lists sessions sorted by last modified date - Shows first message, created/modified dates, message count - Automatically truncates long messages and formats dates - Adds --resume/-r flag to CLI - Session selector integrates with main flow --- packages/coding-agent/src/main.ts | 47 +++++++- packages/coding-agent/src/session-manager.ts | 95 +++++++++++++++ .../coding-agent/src/tui/session-selector.ts | 108 ++++++++++++++++++ 3 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/src/tui/session-selector.ts diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0e61fd50..1afe21a2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -1,11 +1,13 @@ import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent"; import { getModel } from "@mariozechner/pi-ai"; +import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; import chalk from "chalk"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { SessionManager } from "./session-manager.js"; import { codingTools } from "./tools/index.js"; +import { SessionSelectorComponent } from "./tui/session-selector.js"; import { TuiRenderer } from "./tui/tui-renderer.js"; // Get version from package.json @@ -20,6 +22,7 @@ interface Args { apiKey?: string; systemPrompt?: string; continue?: boolean; + resume?: boolean; help?: boolean; messages: string[]; } @@ -36,6 +39,8 @@ function parseArgs(args: string[]): Args { result.help = true; } else if (arg === "--continue" || arg === "-c") { result.continue = true; + } else if (arg === "--resume" || arg === "-r") { + result.resume = true; } else if (arg === "--provider" && i + 1 < args.length) { result.provider = args[++i]; } else if (arg === "--model" && i + 1 < args.length) { @@ -64,6 +69,7 @@ ${chalk.bold("Options:")} --api-key API key (defaults to env vars) --system-prompt System prompt (default: coding assistant prompt) --continue, -c Continue previous session + --resume, -r Select a session to resume --help, -h Show this help ${chalk.bold("Examples:")} @@ -114,6 +120,30 @@ Guidelines: Current directory: ${process.cwd()}`; +async function selectSession(sessionManager: SessionManager): Promise { + return new Promise((resolve) => { + const ui = new TUI(new ProcessTerminal()); + let selectedPath: string | null = null; + + const selector = new SessionSelectorComponent( + sessionManager, + (path: string) => { + selectedPath = path; + ui.stop(); + resolve(path); + }, + () => { + ui.stop(); + resolve(null); + }, + ); + + ui.addChild(selector); + ui.setFocus(selector.getSelectList()); + ui.start(); + }); +} + async function runInteractiveMode(agent: Agent, sessionManager: SessionManager, version: string): Promise { const renderer = new TuiRenderer(agent, sessionManager, version); @@ -176,7 +206,18 @@ export async function main(args: string[]) { } // Setup session manager - const sessionManager = new SessionManager(parsed.continue); + const sessionManager = new SessionManager(parsed.continue && !parsed.resume); + + // Handle --resume flag: show session selector + if (parsed.resume) { + const selectedSession = await selectSession(sessionManager); + if (!selectedSession) { + console.log(chalk.dim("No session selected")); + return; + } + // Set the selected session as the active session + sessionManager.setSessionFile(selectedSession); + } // Determine provider and model const provider = (parsed.provider || "anthropic") as any; @@ -259,8 +300,8 @@ export async function main(args: string[]) { }), }); - // Load previous messages if continuing - if (parsed.continue) { + // Load previous messages if continuing or resuming + if (parsed.continue || parsed.resume) { const messages = sessionManager.loadMessages(); if (messages.length > 0) { console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`)); diff --git a/packages/coding-agent/src/session-manager.ts b/packages/coding-agent/src/session-manager.ts index e984b0ec..b1fc32c4 100644 --- a/packages/coding-agent/src/session-manager.ts +++ b/packages/coding-agent/src/session-manager.ts @@ -242,4 +242,99 @@ export class SessionManager { getSessionFile(): string { return this.sessionFile; } + + /** + * Load all sessions for the current directory with metadata + */ + loadAllSessions(): Array<{ + path: string; + id: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; + }> { + const sessions: Array<{ + path: string; + id: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; + }> = []; + + try { + const files = readdirSync(this.sessionDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => join(this.sessionDir, f)); + + for (const file of files) { + try { + const stats = statSync(file); + const content = readFileSync(file, "utf8"); + const lines = content.trim().split("\n"); + + let sessionId = ""; + let created = stats.birthtime; + let messageCount = 0; + let firstMessage = ""; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + + // Extract session ID from first session entry + if (entry.type === "session" && !sessionId) { + sessionId = entry.id; + created = new Date(entry.timestamp); + } + + // Count messages + if (entry.type === "message") { + messageCount++; + + // Get first user message + if (!firstMessage && entry.message.role === "user") { + const textContent = entry.message.content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join(" "); + firstMessage = textContent || ""; + } + } + } catch { + // Skip malformed lines + } + } + + sessions.push({ + path: file, + id: sessionId || "unknown", + created, + modified: stats.mtime, + messageCount, + firstMessage: firstMessage || "(no messages)", + }); + } catch (error) { + // Skip files that can't be read + console.error(`Failed to read session file ${file}:`, error); + } + } + + // Sort by modified date (most recent first) + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + } catch (error) { + console.error("Failed to load sessions:", error); + } + + return sessions; + } + + /** + * Set the session file to an existing session + */ + setSessionFile(path: string): void { + this.sessionFile = path; + this.loadSessionId(); + } } diff --git a/packages/coding-agent/src/tui/session-selector.ts b/packages/coding-agent/src/tui/session-selector.ts new file mode 100644 index 00000000..d8006ec7 --- /dev/null +++ b/packages/coding-agent/src/tui/session-selector.ts @@ -0,0 +1,108 @@ +import { type Component, Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; +import chalk from "chalk"; +import type { SessionManager } from "../session-manager.js"; + +/** + * Dynamic border component that adjusts to viewport width + */ +class DynamicBorder implements Component { + render(width: number): string[] { + return [chalk.blue("─".repeat(Math.max(1, width)))]; + } +} + +interface SessionItem { + path: string; + id: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; +} + +/** + * Component that renders a session selector + */ +export class SessionSelectorComponent extends Container { + private selectList: SelectList; + + constructor(sessionManager: SessionManager, onSelect: (sessionPath: string) => void, onCancel: () => void) { + super(); + + // Load all sessions + const sessions = sessionManager.loadAllSessions(); + + if (sessions.length === 0) { + this.addChild(new DynamicBorder()); + this.addChild(new Text(chalk.gray(" No previous sessions found"), 0, 0)); + this.addChild(new DynamicBorder()); + this.selectList = new SelectList([], 0); + this.addChild(this.selectList); + + // Auto-cancel if no sessions + setTimeout(() => onCancel(), 100); + return; + } + + // Format sessions as select items + const items: SelectItem[] = sessions.map((session) => { + // 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}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return "yesterday"; + if (diffDays < 7) return `${diffDays}d ago`; + + // Fallback to date string + return date.toLocaleDateString(); + }; + + // Truncate first message to single line + const truncatedMessage = session.firstMessage.replace(/\n/g, " ").trim(); + + // Build description with metadata + const created = formatDate(session.created); + const modified = formatDate(session.modified); + const msgCount = `${session.messageCount} msg${session.messageCount !== 1 ? "s" : ""}`; + + const description = `${truncatedMessage}\n${chalk.dim(`Created: ${created} • Modified: ${modified} • ${msgCount}`)}`; + + return { + value: session.path, + label: truncatedMessage.substring(0, 80), + description, + }; + }); + + // Add top border + this.addChild(new DynamicBorder()); + this.addChild(new Text(chalk.bold("Select a session to resume:"), 1, 0)); + + // Create selector + this.selectList = new SelectList(items, Math.min(10, items.length)); + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = () => { + onCancel(); + }; + + this.addChild(this.selectList); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + getSelectList(): SelectList { + return this.selectList; + } +}