mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 10:02:23 +00:00
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:
parent
7813e14492
commit
458702b3a7
3 changed files with 247 additions and 3 deletions
|
|
@ -1,11 +1,13 @@
|
||||||
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent";
|
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent";
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
|
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { SessionManager } from "./session-manager.js";
|
import { SessionManager } from "./session-manager.js";
|
||||||
import { codingTools } from "./tools/index.js";
|
import { codingTools } from "./tools/index.js";
|
||||||
|
import { SessionSelectorComponent } from "./tui/session-selector.js";
|
||||||
import { TuiRenderer } from "./tui/tui-renderer.js";
|
import { TuiRenderer } from "./tui/tui-renderer.js";
|
||||||
|
|
||||||
// Get version from package.json
|
// Get version from package.json
|
||||||
|
|
@ -20,6 +22,7 @@ interface Args {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
continue?: boolean;
|
continue?: boolean;
|
||||||
|
resume?: boolean;
|
||||||
help?: boolean;
|
help?: boolean;
|
||||||
messages: string[];
|
messages: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +39,8 @@ function parseArgs(args: string[]): Args {
|
||||||
result.help = true;
|
result.help = true;
|
||||||
} else if (arg === "--continue" || arg === "-c") {
|
} else if (arg === "--continue" || arg === "-c") {
|
||||||
result.continue = true;
|
result.continue = true;
|
||||||
|
} else if (arg === "--resume" || arg === "-r") {
|
||||||
|
result.resume = true;
|
||||||
} else if (arg === "--provider" && i + 1 < args.length) {
|
} else if (arg === "--provider" && i + 1 < args.length) {
|
||||||
result.provider = args[++i];
|
result.provider = args[++i];
|
||||||
} else if (arg === "--model" && i + 1 < args.length) {
|
} else if (arg === "--model" && i + 1 < args.length) {
|
||||||
|
|
@ -64,6 +69,7 @@ ${chalk.bold("Options:")}
|
||||||
--api-key <key> API key (defaults to env vars)
|
--api-key <key> API key (defaults to env vars)
|
||||||
--system-prompt <text> System prompt (default: coding assistant prompt)
|
--system-prompt <text> System prompt (default: coding assistant prompt)
|
||||||
--continue, -c Continue previous session
|
--continue, -c Continue previous session
|
||||||
|
--resume, -r Select a session to resume
|
||||||
--help, -h Show this help
|
--help, -h Show this help
|
||||||
|
|
||||||
${chalk.bold("Examples:")}
|
${chalk.bold("Examples:")}
|
||||||
|
|
@ -114,6 +120,30 @@ Guidelines:
|
||||||
|
|
||||||
Current directory: ${process.cwd()}`;
|
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> {
|
async function runInteractiveMode(agent: Agent, sessionManager: SessionManager, version: string): Promise<void> {
|
||||||
const renderer = new TuiRenderer(agent, sessionManager, version);
|
const renderer = new TuiRenderer(agent, sessionManager, version);
|
||||||
|
|
||||||
|
|
@ -176,7 +206,18 @@ export async function main(args: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup session manager
|
// 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
|
// Determine provider and model
|
||||||
const provider = (parsed.provider || "anthropic") as any;
|
const provider = (parsed.provider || "anthropic") as any;
|
||||||
|
|
@ -259,8 +300,8 @@ export async function main(args: string[]) {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load previous messages if continuing
|
// Load previous messages if continuing or resuming
|
||||||
if (parsed.continue) {
|
if (parsed.continue || parsed.resume) {
|
||||||
const messages = sessionManager.loadMessages();
|
const messages = sessionManager.loadMessages();
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
|
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
|
||||||
|
|
|
||||||
|
|
@ -242,4 +242,99 @@ export class SessionManager {
|
||||||
getSessionFile(): string {
|
getSessionFile(): string {
|
||||||
return this.sessionFile;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
packages/coding-agent/src/tui/session-selector.ts
Normal file
108
packages/coding-agent/src/tui/session-selector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue