import { matchesKey, Key, truncateToWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import type { Theme } from "@earendil-works/pi-coding-agent"; import type { TrackedFile } from "./state.js"; const VISIBLE_LINES = 30; export class BorderFrame implements Component { constructor( private readonly child: Component, private readonly borderColor: (text: string) => string, ) {} invalidate(): void { this.child.invalidate(); } render(width: number): string[] { if (width <= 4) return this.child.render(width); const innerWidth = Math.max(1, width - 2); const top = this.borderColor(`┌${"─".repeat(innerWidth)}┐`); const bottom = this.borderColor(`└${"─".repeat(innerWidth)}┘`); const childLines = this.child.render(innerWidth); const body = childLines.map((line) => { const safe = truncateToWidth(line, innerWidth, "", true); return this.borderColor("│") + safe + this.borderColor("│"); }); return [top, ...body, bottom]; } } function getDisplayPath(path: string): string { const home = homedir(); return path.startsWith(home) ? "~" + path.slice(home.length) : path; } export class FileViewer { private selected = 0; private fileScrollOffset = 0; private contentScrollOffset = 0; private focusedPane: 'left' | 'right' = 'left'; private splitRatio = 0.4; private lastLines?: string[]; private lastWidth?: number; constructor( public readonly files: TrackedFile[], private cwd: string, private contentCache: Map, private tui: { requestRender: () => void }, private theme: Theme, ) {} invalidate(): void { this.lastLines = undefined; this.lastWidth = undefined; } async loadContent(path: string): Promise { if (this.contentCache.has(path)) return; try { this.contentCache.set(path, await readFile(path, "utf-8")); } catch { this.contentCache.set(path, "[Error reading file]"); } this.invalidate(); this.tui.requestRender(); } handleInput(data: string): boolean { if (matchesKey(data, Key.ctrl("left"))) { this.splitRatio = Math.max(0.15, this.splitRatio - 0.05); this.invalidate(); this.tui.requestRender(); return true; } if (matchesKey(data, Key.ctrl("right"))) { this.splitRatio = Math.min(0.75, this.splitRatio + 0.05); this.invalidate(); this.tui.requestRender(); return true; } if (matchesKey(data, Key.left) || data === "h") { this.focusedPane = 'left'; this.invalidate(); this.tui.requestRender(); return true; } if (matchesKey(data, Key.right) || data === "l") { this.focusedPane = 'right'; this.invalidate(); this.tui.requestRender(); return true; } if (matchesKey(data, Key.up) || data === "k") { if (this.focusedPane === 'left') { this.move(-1); } else { this.scrollContent(-1); } return true; } if (matchesKey(data, Key.down) || data === "j") { if (this.focusedPane === 'left') { this.move(1); } else { this.scrollContent(1); } return true; } if (data === "\x1b[5~") { if (this.focusedPane === 'left') { this.move(-20); } else { this.scrollContent(-20); } return true; } if (data === "\x1b[6~") { if (this.focusedPane === 'left') { this.move(20); } else { this.scrollContent(20); } return true; } return false; } getSelectedFile(): string | null { return this.files[this.selected]?.path ?? null; } private move(delta: number): void { const next = this.selected + delta; if (next >= 0 && next < this.files.length) { this.selected = next; this.adjustFileScroll(); this.contentScrollOffset = 0; this.invalidate(); const file = this.files[this.selected]; if (file) this.loadContent(file.path); } } private scrollContent(delta: number): void { const file = this.files[this.selected]; if (!file) return; const content = this.contentCache.get(file.path) ?? ""; const lines = content.split("\n"); const next = this.contentScrollOffset + delta; if (next >= 0 && next < lines.length) { this.contentScrollOffset = next; this.invalidate(); this.tui.requestRender(); } } private adjustFileScroll(): void { if (this.selected < this.fileScrollOffset) { this.fileScrollOffset = this.selected; } else if (this.selected >= this.fileScrollOffset + VISIBLE_LINES) { this.fileScrollOffset = this.selected - (VISIBLE_LINES - 1); } } render(width: number): string[] { if (this.lastLines && this.lastWidth === width) return this.lastLines; if (this.files.length === 0) { return ["", " No files tracked yet."]; } const lines: string[] = []; const current = this.files[this.selected]; const t = this.theme; // Header removed - direct file list // Split pane const gutter = t.fg("borderMuted", " │ "); const gutterW = 3; const listW = Math.floor((width - gutterW) * this.splitRatio); const contentW = width - gutterW - listW; // Column headers const leftLabel = this.focusedPane === 'left' ? t.bold(t.fg("accent", "> Files")) : t.bold(t.fg("muted", " Files")); const rightLabel = this.focusedPane === 'right' ? t.bold(t.fg("accent", "> Content")) : t.bold(t.fg("muted", " Content")); const lh = truncateToWidth(leftLabel, listW, "", true); const rh = truncateToWidth(rightLabel, contentW, "", true); const div = t.fg("borderMuted", `${"─".repeat(listW)}─┼─${"─".repeat(contentW)}`); lines.push(lh + gutter + rh); lines.push(div); // Content const content = current ? (this.contentCache.get(current.path) ?? "[Loading...]") : ""; const allContentLines = wrapTextWithAnsi(content, contentW); const contentLines = allContentLines.slice(this.contentScrollOffset, this.contentScrollOffset + VISIBLE_LINES); // Pre-compute wrapped lines for each file type FileLine = { text: string; fileIndex: number; isFirstLine: boolean }; const fileLines: FileLine[] = []; const continuationIndent = " "; // matches prefix width for (let i = this.fileScrollOffset; i < this.files.length && fileLines.length < VISIBLE_LINES; i++) { const displayPath = getDisplayPath(this.files[i].path); const prefix = i === this.selected ? " ▸ " : continuationIndent; const wrapped = wrapTextWithAnsi(displayPath, listW - prefix.length); for (let j = 0; j < wrapped.length && fileLines.length < VISIBLE_LINES; j++) { const linePrefix = j === 0 ? prefix : continuationIndent; fileLines.push({ text: linePrefix + wrapped[j], fileIndex: i, isFirstLine: j === 0 }); } } const leftFocused = this.focusedPane === 'left'; const max = VISIBLE_LINES; for (let i = 0; i < max; i++) { let left = " ".repeat(listW); if (i < fileLines.length) { const fl = fileLines[i]; const sel = fl.fileIndex === this.selected; const isFocused = sel && leftFocused; if (isFocused) { left = `\x1b[7m${fl.text}\x1b[27m`; } else if (sel) { left = fl.text; } else { left = fl.text; } left = truncateToWidth(left, listW, " ", true); } let right = ""; if (i < contentLines.length) { const contentText = ` ${contentLines[i]}`; right = truncateToWidth(contentText, contentW, " ", true); } lines.push(left + gutter + right); } lines.push("─".repeat(width)); lines.push(t.fg("dim", "up/down navigate • PgUp/PgDn page • left/right switch pane • Ctrl+←/→ resize • Enter open • Esc close")); this.lastLines = lines; this.lastWidth = width; return lines; } }