co-mono/packages/coding-agent/src/modes/interactive/components/session-selector.ts
Thomas Mustier e8d91f2bd4 feat(coding-agent): add resume scope toggle
refactor(coding-agent): refine session listing helpers
2026-01-11 20:51:20 +00:00

273 lines
8.4 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 } 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;
constructor(scope: SessionScope) {
this.scope = scope;
}
setScope(scope: SessionScope): void {
this.scope = scope;
}
invalidate(): void {}
render(width: number): string[] {
const title = this.scope === "current" ? "Resume Session (Current Folder)" : "Resume Session (All)";
const leftText = theme.bold(title);
const 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));
return [`${left}${" ".repeat(spacing)}${rightText}`];
}
}
/**
* 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());
}
}
}
/**
* Component that renders a session selector
*/
export class SessionSelectorComponent extends Container {
private sessionList: SessionList;
private header: SessionSelectorHeader;
private scope: SessionScope = "current";
private currentSessions: SessionInfo[];
private allSessions: SessionInfo[];
constructor(
currentSessions: SessionInfo[],
allSessions: SessionInfo[],
onSelect: (sessionPath: string) => void,
onCancel: () => void,
onExit: () => void,
) {
super();
this.currentSessions = currentSessions;
this.allSessions = allSessions;
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
this.sessionList = new SessionList(this.currentSessions, this.scope === "all");
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());
// Auto-cancel if no sessions
if (currentSessions.length === 0 && allSessions.length === 0) {
setTimeout(() => onCancel(), 100);
}
}
private toggleScope(): void {
this.scope = this.scope === "current" ? "all" : "current";
const sessions = this.scope === "current" ? this.currentSessions : this.allSessions;
this.sessionList.setSessions(sessions, this.scope === "all");
this.header.setScope(this.scope);
}
getSessionList(): SessionList {
return this.sessionList;
}
}