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
This commit is contained in:
Mario Zechner 2025-11-12 09:17:04 +01:00
parent 7813e14492
commit 458702b3a7
3 changed files with 247 additions and 3 deletions

View file

@ -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 <key> API key (defaults to env vars)
--system-prompt <text> 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<string | null> {
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<void> {
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`));

View file

@ -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();
}
}

View file

@ -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;
}
}