import type { Component } from "@mariozechner/pi-tui"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import type { KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent"; import type { SessionInfo } from "../types.js"; function middleTruncate(text: string, maxWidth: number): string { if (visibleWidth(text) <= maxWidth) { return text; } if (maxWidth <= 3) { return truncateToWidth(text, maxWidth, ""); } const chars = [...text]; const targetSideWidth = Math.max(1, Math.floor((maxWidth - 1) / 2)); let left = ""; for (const char of chars) { if (visibleWidth(left + char) > targetSideWidth) break; left += char; } let right = ""; for (const char of chars.slice().reverse()) { if (visibleWidth(char + right) > targetSideWidth) break; right = char + right; } return truncateToWidth(`${left}…${right}`, maxWidth, ""); } function shortSessionId(sessionId: string): string { return sessionId.slice(0, 8); } function sessionTitle(session: SessionInfo, options?: { self?: boolean; sameCwd?: boolean }): string { const name = session.name || "Unnamed session"; const tags = [options?.self ? "self" : undefined, options?.sameCwd ? "same cwd" : undefined] .filter((tag): tag is string => Boolean(tag)); const suffix = tags.length ? ` [${tags.join(", ")}]` : ""; return `${name} (${shortSessionId(session.id)})${suffix}`; } export class SessionListOverlay implements Component { private theme: Theme; private keybindings: KeybindingsManager; private currentSession: SessionInfo; private done: (result: SessionInfo | undefined) => void; private sessions: SessionInfo[]; private selectedIndex = 0; private maxVisible = 8; constructor( theme: Theme, keybindings: KeybindingsManager, currentSession: SessionInfo, sessions: SessionInfo[], done: (result: SessionInfo | undefined) => void, ) { this.theme = theme; this.keybindings = keybindings; this.currentSession = currentSession; this.sessions = sessions; this.done = done; } private onSessionSelect(sessionId: string): void { const session = this.sessions.find(s => s.id === sessionId); if (!session) return; this.done(session); } invalidate(): void {} handleInput(data: string): void { if (this.keybindings.matches(data, "tui.select.cancel")) { this.done(undefined); return; } if (this.sessions.length === 0) { return; } if (this.keybindings.matches(data, "tui.select.up")) { this.selectedIndex = this.selectedIndex === 0 ? this.sessions.length - 1 : this.selectedIndex - 1; return; } if (this.keybindings.matches(data, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.sessions.length - 1 ? 0 : this.selectedIndex + 1; return; } if (this.keybindings.matches(data, "tui.select.confirm")) { const session = this.sessions[this.selectedIndex]; if (session) { this.onSessionSelect(session.id); } } } render(width: number): string[] { const innerWidth = Math.max(36, Math.min(width - 2, 88)); const contentWidth = Math.max(1, innerWidth - 2); const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Message • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`; const border = (text: string) => this.theme.fg("accent", text); const row = (text = "") => { const clipped = truncateToWidth(text, contentWidth, "", true); return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`; }; const lines: string[] = []; lines.push(border(`╭${"─".repeat(contentWidth)}╮`)); lines.push(row(this.theme.bold(" Current Session"))); lines.push(border(`├${"─".repeat(contentWidth)}┤`)); lines.push(row()); lines.push(row(` ${this.theme.fg("dim", sessionTitle(this.currentSession, { self: true }))}`)); lines.push(row(` ${this.theme.fg("dim", `${middleTruncate(this.currentSession.cwd, Math.max(8, contentWidth - 4))} • ${this.currentSession.model}`)}`)); lines.push(row()); lines.push(border(`├${"─".repeat(contentWidth)}┤`)); lines.push(row(this.theme.bold(" Other Sessions"))); lines.push(row()); if (this.sessions.length === 0) { lines.push(row(this.theme.fg("dim", " No other intercom-connected sessions"))); } else { const startIndex = Math.max( 0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.sessions.length - this.maxVisible), ); const endIndex = Math.min(startIndex + this.maxVisible, this.sessions.length); for (let index = startIndex; index < endIndex; index += 1) { const session = this.sessions[index]; const isSelected = index === this.selectedIndex; const sameCwd = session.cwd === this.currentSession.cwd; const prefix = isSelected ? this.theme.fg("accent", "→ ") : " "; const title = sessionTitle(session, { sameCwd }); const pathText = `${middleTruncate(session.cwd, Math.max(8, contentWidth - 4))} • ${session.model}`; lines.push(row(`${prefix}${isSelected ? this.theme.fg("accent", title) : title}`)); lines.push(row(` ${this.theme.fg("dim", pathText)}`)); if (index < endIndex - 1) { lines.push(row()); } } if (startIndex > 0 || endIndex < this.sessions.length) { lines.push(row()); lines.push(row(this.theme.fg("dim", ` ${this.selectedIndex + 1}/${this.sessions.length}`))); } } lines.push(row()); lines.push(border(`├${"─".repeat(contentWidth)}┤`)); lines.push(row(this.theme.fg("dim", ` ${footer}`))); lines.push(border(`╰${"─".repeat(contentWidth)}╯`)); return lines; } }