Files
sproutclaw/packages/coding-agent/src/modes/interactive/components/session-selector.ts
Mario Zechner ace8ea3d5b Refactor SessionManager to use static factory methods
- Add factory methods: create(cwd), open(path), continueRecent(cwd), inMemory()
- Add static list(cwd) for session listing
- Make constructor private, pass cwd explicitly
- Update SDK to take sessionManager instead of sessionFile options
- Update main.ts to create SessionManager based on CLI flags
- Update SessionSelectorComponent to take sessions[] instead of SessionManager
- Update tests to use factory methods
2025-12-22 01:29:54 +01:00

205 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type Component,
Container,
Input,
isArrowDown,
isArrowUp,
isCtrlC,
isEnter,
isEscape,
Spacer,
Text,
truncateToWidth,
} from "@mariozechner/pi-tui";
import type { SessionInfo } from "../../../core/session-manager.js";
import { fuzzyFilter } from "../../../utils/fuzzy.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/**
* Custom session list component with multi-line items and search
*/
class SessionList implements Component {
private allSessions: SessionInfo[] = [];
private filteredSessions: SessionInfo[] = [];
private selectedIndex: number = 0;
private searchInput: Input;
public onSelect?: (sessionPath: string) => void;
public onCancel?: () => void;
public onExit: () => void = () => {};
private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
constructor(sessions: SessionInfo[]) {
this.allSessions = sessions;
this.filteredSessions = sessions;
this.searchInput = new Input();
// Handle Enter in search input - select current item
this.searchInput.onSubmit = () => {
if (this.filteredSessions[this.selectedIndex]) {
const selected = this.filteredSessions[this.selectedIndex];
if (this.onSelect) {
this.onSelect(selected.path);
}
}
};
}
private filterSessions(query: string): void {
this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// Render search input
lines.push(...this.searchInput.render(width));
lines.push(""); // Blank line after search
if (this.filteredSessions.length === 0) {
lines.push(theme.fg("muted", " No sessions found"));
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,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);
// Render visible sessions (2 lines per session + blank line)
for (let i = startIndex; i < endIndex; i++) {
const session = this.filteredSessions[i];
const isSelected = i === this.selectedIndex;
// Normalize first message to single line
const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim();
// First line: cursor + message (truncate to visible width)
const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor (2 visible chars)
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "...");
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
// Second line: metadata (dimmed) - also truncate for safety
const modified = formatDate(session.modified);
const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
const metadata = ` ${modified} · ${msgCount}`;
const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, ""));
lines.push(messageLine);
lines.push(metadataLine);
lines.push(""); // Blank line between sessions
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredSessions.length) {
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`;
const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, ""));
lines.push(scrollInfo);
}
return lines;
}
handleInput(keyData: string): void {
// Up arrow
if (isArrowUp(keyData)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
}
// Down arrow
else if (isArrowDown(keyData)) {
this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);
}
// Enter
else if (isEnter(keyData)) {
const selected = this.filteredSessions[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.path);
}
}
// Escape - cancel
else if (isEscape(keyData)) {
if (this.onCancel) {
this.onCancel();
}
}
// Ctrl+C - exit
else if (isCtrlC(keyData)) {
this.onExit();
}
// Pass everything else to search input
else {
this.searchInput.handleInput(keyData);
this.filterSessions(this.searchInput.getValue());
}
}
}
/**
* Component that renders a session selector
*/
export class SessionSelectorComponent extends Container {
private sessionList: SessionList;
constructor(
sessions: SessionInfo[],
onSelect: (sessionPath: string) => void,
onCancel: () => void,
onExit: () => void,
) {
super();
// Add header
this.addChild(new Spacer(1));
this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Create session list
this.sessionList = new SessionList(sessions);
this.sessionList.onSelect = onSelect;
this.sessionList.onCancel = onCancel;
this.sessionList.onExit = onExit;
this.addChild(this.sessionList);
// Add bottom border
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
// Auto-cancel if no sessions
if (sessions.length === 0) {
setTimeout(() => onCancel(), 100);
}
}
getSessionList(): SessionList {
return this.sessionList;
}
}