Files
sproutclaw/packages/coding-agent/src/modes/interactive/components/session-selector.ts
Mario Zechner 302404684f feat(coding-agent): add resume scope toggle with async loading
- /resume and --resume now toggle between Current Folder and All sessions with Tab
- SessionManager.list() and listAll() are now async with optional progress callback
- Shows loading progress (e.g. Loading 5/42) while scanning sessions
- SessionInfo.cwd field shows session working directory in All view
- Lazy loading: All sessions only loaded when user presses Tab

closes #619

Co-authored-by: Thomas Mustier <mustierthomas@gmail.com>
2026-01-12 00:00:03 +01:00

348 lines
11 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 * as os from "node:os";
import {
type Component,
Container,
fuzzyFilter,
getEditorKeybindings,
Input,
Spacer,
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
type SessionScope = "current" | "all";
function shortenPath(path: string): string {
const home = os.homedir();
if (!path) return path;
if (path.startsWith(home)) {
return `~${path.slice(home.length)}`;
}
return path;
}
function formatSessionDate(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();
}
class SessionSelectorHeader implements Component {
private scope: SessionScope;
private loading = false;
private loadProgress: { loaded: number; total: number } | null = null;
constructor(scope: SessionScope) {
this.scope = scope;
}
setScope(scope: SessionScope): void {
this.scope = scope;
}
setLoading(loading: boolean): void {
this.loading = loading;
if (!loading) {
this.loadProgress = null;
}
}
setProgress(loaded: number, total: number): void {
this.loadProgress = { loaded, total };
}
invalidate(): void {}
render(width: number): string[] {
const title = this.scope === "current" ? "Resume Session (Current Folder)" : "Resume Session (All)";
const leftText = theme.bold(title);
let scopeText: string;
if (this.loading) {
const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "...";
scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", `Loading ${progressText}`)}`;
} else {
scopeText =
this.scope === "current"
? `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}`
: `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`;
}
const rightText = truncateToWidth(scopeText, width, "");
const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1);
const left = truncateToWidth(leftText, availableLeft, "");
const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText));
const hint = theme.fg("muted", "Tab to toggle scope");
return [`${left}${" ".repeat(spacing)}${rightText}`, hint];
}
}
/**
* 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 = () => {};
public onToggleScope?: () => void;
private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
constructor(sessions: SessionInfo[], showCwd: boolean) {
this.allSessions = sessions;
this.filteredSessions = sessions;
this.searchInput = new Input();
this.showCwd = showCwd;
// 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);
}
}
};
}
setSessions(sessions: SessionInfo[], showCwd: boolean): void {
this.allSessions = sessions;
this.showCwd = showCwd;
this.filterSessions(this.searchInput.getValue());
}
private filterSessions(query: string): void {
this.filteredSessions = fuzzyFilter(
this.allSessions,
query,
(session) => `${session.id} ${session.allMessagesText} ${session.cwd}`,
);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
}
invalidate(): void {}
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;
}
// 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 = formatSessionDate(session.modified);
const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
const metadataParts = [modified, msgCount];
if (this.showCwd && session.cwd) {
metadataParts.push(shortenPath(session.cwd));
}
const metadata = ` ${metadataParts.join(" · ")}`;
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 {
const kb = getEditorKeybindings();
if (kb.matches(keyData, "tab")) {
if (this.onToggleScope) {
this.onToggleScope();
}
return;
}
// Up arrow
if (kb.matches(keyData, "selectUp")) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
}
// Down arrow
else if (kb.matches(keyData, "selectDown")) {
this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);
}
// Enter
else if (kb.matches(keyData, "selectConfirm")) {
const selected = this.filteredSessions[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.path);
}
}
// Escape - cancel
else if (kb.matches(keyData, "selectCancel")) {
if (this.onCancel) {
this.onCancel();
}
}
// Pass everything else to search input
else {
this.searchInput.handleInput(keyData);
this.filterSessions(this.searchInput.getValue());
}
}
}
type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;
/**
* Component that renders a session selector
*/
export class SessionSelectorComponent extends Container {
private sessionList: SessionList;
private header: SessionSelectorHeader;
private scope: SessionScope = "current";
private currentSessions: SessionInfo[] | null = null;
private allSessions: SessionInfo[] | null = null;
private currentSessionsLoader: SessionsLoader;
private allSessionsLoader: SessionsLoader;
private onCancel: () => void;
private requestRender: () => void;
constructor(
currentSessionsLoader: SessionsLoader,
allSessionsLoader: SessionsLoader,
onSelect: (sessionPath: string) => void,
onCancel: () => void,
onExit: () => void,
requestRender: () => void,
) {
super();
this.currentSessionsLoader = currentSessionsLoader;
this.allSessionsLoader = allSessionsLoader;
this.onCancel = onCancel;
this.requestRender = requestRender;
this.header = new SessionSelectorHeader(this.scope);
// Add header
this.addChild(new Spacer(1));
this.addChild(this.header);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Create session list (starts empty, will be populated after load)
this.sessionList = new SessionList([], false);
this.sessionList.onSelect = onSelect;
this.sessionList.onCancel = onCancel;
this.sessionList.onExit = onExit;
this.sessionList.onToggleScope = () => this.toggleScope();
this.addChild(this.sessionList);
// Add bottom border
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
// Start loading current sessions immediately
this.loadCurrentSessions();
}
private loadCurrentSessions(): void {
this.header.setLoading(true);
this.requestRender();
this.currentSessionsLoader((loaded, total) => {
this.header.setProgress(loaded, total);
this.requestRender();
}).then((sessions) => {
this.currentSessions = sessions;
this.header.setLoading(false);
this.sessionList.setSessions(sessions, false);
this.requestRender();
// If no sessions found, cancel
if (sessions.length === 0) {
this.onCancel();
}
});
}
private toggleScope(): void {
if (this.scope === "current") {
// Switching to "all" - load if not already loaded
if (this.allSessions === null) {
this.header.setLoading(true);
this.header.setScope("all");
this.sessionList.setSessions([], true); // Clear list while loading
this.requestRender();
// Load asynchronously with progress updates
this.allSessionsLoader((loaded, total) => {
this.header.setProgress(loaded, total);
this.requestRender();
}).then((sessions) => {
this.allSessions = sessions;
this.header.setLoading(false);
this.scope = "all";
this.sessionList.setSessions(this.allSessions, true);
this.requestRender();
// If no sessions in All scope either, cancel
if (this.allSessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
this.onCancel();
}
});
} else {
this.scope = "all";
this.sessionList.setSessions(this.allSessions, true);
this.header.setScope(this.scope);
}
} else {
// Switching back to "current"
this.scope = "current";
this.sessionList.setSessions(this.currentSessions ?? [], false);
this.header.setScope(this.scope);
}
}
getSessionList(): SessionList {
return this.sessionList;
}
}