import { type Component, Container, Input, matchesKey, padding, Spacer, Text, truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import type { SessionInfo } from "../../session/session-manager"; import { fuzzyFilter } from "../../utils/fuzzy"; import { DynamicBorder } from "./dynamic-border"; /** * 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; private showCwd = false; 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[], showCwd = false) { this.allSessions = sessions; this.filteredSessions = sessions; this.showCwd = showCwd; 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 => { const parts = [ session.id, session.title ?? "", session.cwd ?? "", session.firstMessage ?? "", session.allMessagesText, session.path, ]; return parts.filter(Boolean).join(" "); }); 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) { if (this.showCwd) { // "All" scope - no sessions anywhere that match filter lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width, theme.format.ellipsis)); } else { // "Current folder" scope - hint to try "all" lines.push( truncateToWidth( theme.fg("muted", " No sessions in current folder. Press Tab to view all."), width, theme.format.ellipsis, ), ); } 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-3 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 + title (or first message if no title) const cursorSymbol = `${theme.nav.cursor} `; const cursorWidth = visibleWidth(cursorSymbol); const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth); const maxWidth = width - cursorWidth; // Account for cursor width if (session.title) { // Has title: show title on first line, dimmed first message on second line const truncatedTitle = truncateToWidth(session.title, maxWidth, theme.format.ellipsis); const titleLine = cursor + (isSelected ? theme.bold(truncatedTitle) : truncatedTitle); lines.push(titleLine); // Second line: dimmed first message preview const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis); lines.push(` ${theme.fg("dim", truncatedPreview)}`); } else { // No title: show first message as main line const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis); const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); lines.push(messageLine); } // Metadata line: date + message count const modified = formatDate(session.modified); const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; const metadata = ` ${modified} ${theme.sep.dot} ${msgCount}`; const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, theme.format.ellipsis)); 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, theme.format.ellipsis)); lines.push(scrollInfo); } return lines; } handleInput(keyData: string): void { // Up arrow if (matchesKey(keyData, "up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } // Down arrow else if (matchesKey(keyData, "down")) { this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1); } // Page up - jump up by maxVisible items else if (matchesKey(keyData, "pageUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible); } // Page down - jump down by maxVisible items else if (matchesKey(keyData, "pageDown")) { this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible); } // Enter else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const selected = this.filteredSessions[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.path); } } // Escape - cancel else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { if (this.onCancel) { this.onCancel(); } } // Ctrl+C - exit else if (matchesKey(keyData, "ctrl+c")) { 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()); } getSessionList(): SessionList { return this.sessionList; } }