import type { Theme } from "@earendil-works/pi-coding-agent"; import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, } from "@earendil-works/pi-tui"; import { MAX_FILE_PATCH_BYTES, MAX_PATCH_LINES, type FileChange, type PatchOmissionReason, type TaskSummary, } from "./git.ts"; import { displayPath, fileCounts, fileIcon } from "./format.ts"; import { parseUnifiedPatch, sanitizeTerminalText, type PatchLineKind } from "./patch.ts"; type Focus = "tasks" | "files" | "detail"; export type AnalysisView = "explanation" | "rationale"; type DetailView = "diff" | AnalysisView; export interface OverlayTask { summary: TaskSummary; hash: string; taskId: string; taskRequest: string | undefined; timestamp: number | undefined; summaryEntryId: string | undefined; contextPersisted: boolean; } type AnalyzeFile = ( task: OverlayTask, file: FileChange, view: AnalysisView, signal: AbortSignal, onProgress: (partial: string) => void, ) => Promise; interface DisplayLine { kind: PatchLineKind | "message" | "explanation" | "heading"; text: string; } const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; interface ActiveAnalysis { taskIndex: number; fileIndex: number; view: AnalysisView; controller: AbortController; startedAt: number; frame: number; partial: string; timer: ReturnType | undefined; } function clamp(value: number, minimum: number, maximum: number): number { return Math.min(maximum, Math.max(minimum, value)); } function omissionMessage(reason: PatchOmissionReason): string { switch (reason) { case "file-too-large": return "Diff omitted because this file's changes are too large."; case "task-budget": return "Diff omitted because the task diff budget was reached."; case "file-limit": return "Diff omitted because the task changed too many files."; case "time-limit": return "Diff omitted because diff collection timed out."; case "error": return "Diff unavailable because Git could not produce it."; } } function truncateLeftToWidth(text: string, width: number): string { if (width <= 0) return ""; if (visibleWidth(text) <= width) return text; if (width === 1) return "…"; const suffix: string[] = []; let suffixWidth = 0; for (const character of Array.from(text).reverse()) { const characterWidth = visibleWidth(character); if (suffixWidth + characterWidth > width - 1) break; suffix.unshift(character); suffixWidth += characterWidth; } return `…${suffix.join("")}`; } function errorMessage(error: unknown): string { if (error instanceof Error && error.message) { return sanitizeTerminalText(error.message).slice(0, 500); } return "The model could not generate the requested analysis."; } function taskTime(timestamp: number | undefined): string { if (timestamp === undefined || !Number.isFinite(timestamp)) return "--:--"; const date = new Date(timestamp); if (!Number.isFinite(date.getTime())) return "--:--"; return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; } export class SummaryOverlay implements Component { private focus: Focus = "tasks"; private detailView: DetailView = "diff"; private selectedTaskIndex = 0; private selectedFileIndex = 0; private taskOffset = 0; private fileOffset = 0; private detailOffset = 0; private lastDetailWidth = 80; private closed = false; private readonly patchCache = new Map(); private readonly generatedAnalyses = new Map(); private readonly tasks: OverlayTask[]; private readonly theme: Theme; private readonly close: () => void; private readonly requestRender: () => void; private readonly getContentRows: () => number; private readonly getAnalysis: ( task: OverlayTask, file: FileChange, view: AnalysisView, ) => string | undefined; private readonly getProducedAttribution: ( task: OverlayTask, file: FileChange, view: AnalysisView, ) => string | undefined; private readonly configuredAttribution: string; private readonly analyzeFile: AnalyzeFile; private activeAnalysis: ActiveAnalysis | undefined; private analysisError: { taskIndex: number; fileIndex: number; view: AnalysisView; message: string; } | undefined; constructor( tasks: OverlayTask[], theme: Theme, close: () => void, requestRender: () => void, getContentRows: () => number, getAnalysis: ( task: OverlayTask, file: FileChange, view: AnalysisView, ) => string | undefined, getProducedAttribution: ( task: OverlayTask, file: FileChange, view: AnalysisView, ) => string | undefined, configuredAttribution: string, analyzeFile: AnalyzeFile, ) { this.tasks = tasks; this.theme = theme; this.close = close; this.requestRender = requestRender; this.getContentRows = getContentRows; this.getAnalysis = getAnalysis; this.getProducedAttribution = getProducedAttribution; this.configuredAttribution = configuredAttribution; this.analyzeFile = analyzeFile; } handleInput(data: string): void { if ( matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "return") || matchesKey(data, "f6") ) { this.closeOverlay(); return; } const previousState = this.stateKey(); if (data === "d" || data === "D") { this.showDiff(); } else if (data === "e" || data === "E") { this.showAnalysis("explanation"); } else if (data === "w" || data === "W") { this.showAnalysis("rationale"); } else if ((data === "r" || data === "R") && this.detailView !== "diff") { this.regenerateAnalysis(); } else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) { this.focus = this.previousFocus(this.focus); } else if (matchesKey(data, "tab") || matchesKey(data, "right")) { this.focus = this.nextFocus(this.focus); } else if (this.focus === "tasks") { this.handleTaskInput(data); } else if (this.focus === "files") { this.handleFileInput(data); } else { this.handleDetailInput(data); } if (this.stateKey() !== previousState) this.requestRender(); } render(width: number): string[] { const safeWidth = Math.max(0, Math.floor(width)); if (safeWidth < 48) { return [truncateToWidth( this.theme.fg("muted", "Widen terminal for task history and Git diff"), safeWidth, "", )]; } const innerWidth = safeWidth - 2; const contentRows = this.contentRows(); this.ensureSelectedTaskVisible(contentRows); this.ensureSelectedFileVisible(contentRows); const paneWidth = innerWidth - 2; let taskWidth = clamp(Math.floor(innerWidth * 0.24), 20, 24); let fileWidth = clamp(Math.floor(innerWidth * 0.27), 14, 28); let detailWidth = paneWidth - taskWidth - fileWidth; if (detailWidth < 16) { const fileReduction = Math.min(fileWidth - 10, 16 - detailWidth); fileWidth -= fileReduction; detailWidth += fileReduction; } if (detailWidth < 16) { const taskReduction = Math.min(taskWidth - 14, 16 - detailWidth); taskWidth -= taskReduction; detailWidth += taskReduction; } detailWidth = Math.max(0, paneWidth - taskWidth - fileWidth); this.lastDetailWidth = detailWidth; const task = this.currentTask(); const summary = task?.summary; const selected = summary?.files[this.selectedFileIndex]; const detailLines = this.currentDetailLines(detailWidth); const maxDetailOffset = Math.max(0, detailLines.length - contentRows); this.detailOffset = Math.min(this.detailOffset, maxDetailOffset); const detailLabel = this.detailView === "diff" ? "diff" : this.detailView === "explanation" ? "explanation" : "why"; const top = this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`); const paneHeader = this.paneHeader(taskWidth, fileWidth, detailWidth); const separator = this.paneSeparator(taskWidth, fileWidth, detailWidth); const bottom = this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`); const titlePath = selected ? displayPath(selected.file) : "No changed files"; const viewBanner = this.detailView === "diff" ? "[ DIFF ]" : this.detailView === "explanation" ? "[ EXPLANATION ]" : "[ RATIONAL ]"; const title = ` Task ${this.selectedTaskIndex + 1}/${this.tasks.length} • File ${this.selectedFileIndex + 1}/${summary?.files.length ?? 0} • ${titlePath}`; const storedAttribution = this.detailView !== "diff" && task && selected ? this.getProducedAttribution(task, selected, this.detailView) : undefined; const producedAttribution = this.detailView !== "diff" && selected ? this.isAnalysisLoading() ? `(Producing with ${this.configuredAttribution})` : storedAttribution ? `(Produced by ${storedAttribution})` : "" : ""; const titleAttribution = sanitizeTerminalText(producedAttribution); const focusAction = this.focus === "tasks" ? "task" : this.focus === "files" ? "file" : "scroll"; const detailEnd = Math.min(detailLines.length, this.detailOffset + contentRows); const range = detailLines.length > contentRows ? ` • ${this.detailOffset + 1}-${detailEnd}/${detailLines.length}` : ""; const viewActions = "D diff • E explain • W why"; const regenerationAction = this.detailView !== "diff" && !this.isAnalysisLoading() ? " • R regenerate" : ""; const footer = ` ${viewActions}${regenerationAction} • ↑/↓ ${focusAction} • ←/→ tasks/files/${detailLabel} • PgUp/Dn • Enter/Esc close${range}`; const configuredAttribution = truncateLeftToWidth( sanitizeTerminalText(this.configuredAttribution), Math.max(0, Math.floor(innerWidth * 0.5)), ); const lines = [ top, this.fullHeaderRow(title, viewBanner, titleAttribution, innerWidth), paneHeader, ]; for (let rowIndex = 0; rowIndex < contentRows; rowIndex += 1) { const taskIndex = this.taskOffset + rowIndex; const fileIndex = this.fileOffset + rowIndex; const detailIndex = this.detailOffset + rowIndex; const taskLine = this.taskLine(taskIndex, taskWidth); const fileLine = this.fileLine(fileIndex, fileWidth); const detail = this.detailLine(detailLines[detailIndex], detailWidth); lines.push( `${this.paneBoundary(0, "│")}${taskLine}${this.paneBoundary(1, "│")}${fileLine}${this.paneBoundary(2, "│")}${detail}${this.paneBoundary(3, "│")}`, ); } lines.push( separator, this.fullSplitRow( this.theme.fg("dim", footer), this.theme.fg("muted", configuredAttribution), innerWidth, ), bottom, ); return lines; } invalidate(): void { this.patchCache.clear(); } dismiss(): void { this.closeOverlay(); } private currentTask(): OverlayTask | undefined { return this.tasks[this.selectedTaskIndex]; } private currentSummary(): TaskSummary | undefined { return this.currentTask()?.summary; } private currentFile(): FileChange | undefined { return this.currentSummary()?.files[this.selectedFileIndex]; } private nextFocus(focus: Focus): Focus { if (focus === "tasks") return "files"; if (focus === "files") return "detail"; return "tasks"; } private previousFocus(focus: Focus): Focus { if (focus === "detail") return "files"; if (focus === "files") return "tasks"; return "detail"; } private closeOverlay(): void { if (this.closed) return; this.closed = true; this.cancelAnalysis(); this.close(); } private showDiff(): void { this.cancelAnalysis(); this.detailView = "diff"; this.detailOffset = 0; this.analysisError = undefined; } private showAnalysis(view: AnalysisView): void { if (this.detailView !== view) { this.cancelAnalysis(); this.detailView = view; this.detailOffset = 0; this.analysisError = undefined; } const task = this.currentTask(); const file = this.currentFile(); if ( !task || !file || this.isAnalysisLoading() || this.analysisFor(this.selectedTaskIndex, this.selectedFileIndex, task, file, view) ) { return; } this.startAnalysis( this.selectedTaskIndex, this.selectedFileIndex, task, file, view, ); } private regenerateAnalysis(): void { if (this.detailView === "diff" || this.isAnalysisLoading()) return; const task = this.currentTask(); const file = this.currentFile(); if (!task || !file) return; this.detailOffset = 0; this.analysisError = undefined; this.startAnalysis( this.selectedTaskIndex, this.selectedFileIndex, task, file, this.detailView, ); } private startAnalysis( taskIndex: number, fileIndex: number, task: OverlayTask, file: FileChange, view: AnalysisView, ): void { this.cancelAnalysis(); const controller = new AbortController(); const request: ActiveAnalysis = { taskIndex, fileIndex, view, controller, startedAt: Date.now(), frame: 0, partial: "", timer: undefined, }; request.timer = setInterval(() => { if (this.activeAnalysis !== request || this.closed) return; request.frame = (request.frame + 1) % SPINNER_FRAMES.length; this.requestRender(); }, 150); request.timer.unref?.(); this.activeAnalysis = request; void this.analyzeFile(task, file, view, controller.signal, (partial) => { if (this.activeAnalysis !== request || this.closed) return; request.partial = partial; this.requestRender(); }) .then((analysis) => { if (this.activeAnalysis !== request || this.closed) return; this.generatedAnalyses.set(this.analysisKey(taskIndex, fileIndex, view), analysis); }) .catch((error: unknown) => { if (this.activeAnalysis !== request || controller.signal.aborted || this.closed) return; this.analysisError = { taskIndex, fileIndex, view, message: errorMessage(error) }; }) .finally(() => { if (request.timer) clearInterval(request.timer); request.timer = undefined; if (this.activeAnalysis !== request) return; this.activeAnalysis = undefined; if (!this.closed) this.requestRender(); }); } private cancelAnalysis(): void { const request = this.activeAnalysis; if (!request) return; this.activeAnalysis = undefined; if (request.timer) clearInterval(request.timer); request.timer = undefined; request.controller.abort(); } private handleTaskInput(data: string): void { const contentRows = this.contentRows(); const lastIndex = Math.max(0, this.tasks.length - 1); let nextIndex = this.selectedTaskIndex; if (matchesKey(data, "up")) nextIndex -= 1; if (matchesKey(data, "down")) nextIndex += 1; if (matchesKey(data, "pageUp")) nextIndex -= contentRows; if (matchesKey(data, "pageDown")) nextIndex += contentRows; if (matchesKey(data, "home")) nextIndex = 0; if (matchesKey(data, "end")) nextIndex = lastIndex; nextIndex = clamp(nextIndex, 0, lastIndex); if (nextIndex === this.selectedTaskIndex) return; this.cancelAnalysis(); this.detailView = "diff"; this.analysisError = undefined; this.selectedTaskIndex = nextIndex; this.selectedFileIndex = 0; this.fileOffset = 0; this.detailOffset = 0; this.ensureSelectedTaskVisible(contentRows); } private handleFileInput(data: string): void { const contentRows = this.contentRows(); const files = this.currentSummary()?.files ?? []; const lastIndex = Math.max(0, files.length - 1); let nextIndex = this.selectedFileIndex; if (matchesKey(data, "up")) nextIndex -= 1; if (matchesKey(data, "down")) nextIndex += 1; if (matchesKey(data, "pageUp")) nextIndex -= contentRows; if (matchesKey(data, "pageDown")) nextIndex += contentRows; if (matchesKey(data, "home")) nextIndex = 0; if (matchesKey(data, "end")) nextIndex = lastIndex; nextIndex = clamp(nextIndex, 0, lastIndex); if (nextIndex === this.selectedFileIndex) return; this.cancelAnalysis(); this.detailView = "diff"; this.analysisError = undefined; this.selectedFileIndex = nextIndex; this.detailOffset = 0; this.ensureSelectedFileVisible(contentRows); } private handleDetailInput(data: string): void { const contentRows = this.contentRows(); const lineCount = this.currentDetailLines(this.lastDetailWidth).length; const maxOffset = Math.max(0, lineCount - contentRows); if (matchesKey(data, "up")) this.detailOffset -= 1; if (matchesKey(data, "down")) this.detailOffset += 1; if (matchesKey(data, "pageUp")) this.detailOffset -= contentRows; if (matchesKey(data, "pageDown")) this.detailOffset += contentRows; if (matchesKey(data, "home")) this.detailOffset = 0; if (matchesKey(data, "end")) this.detailOffset = maxOffset; this.detailOffset = clamp(this.detailOffset, 0, maxOffset); } private ensureSelectedTaskVisible(contentRows: number): void { if (this.selectedTaskIndex < this.taskOffset) { this.taskOffset = this.selectedTaskIndex; } else if (this.selectedTaskIndex >= this.taskOffset + contentRows) { this.taskOffset = this.selectedTaskIndex - contentRows + 1; } } private ensureSelectedFileVisible(contentRows: number): void { if (this.selectedFileIndex < this.fileOffset) { this.fileOffset = this.selectedFileIndex; } else if (this.selectedFileIndex >= this.fileOffset + contentRows) { this.fileOffset = this.selectedFileIndex - contentRows + 1; } } private contentRows(): number { return clamp(Math.floor(this.getContentRows()), 1, 1_000); } private currentDetailLines(width: number): DisplayLine[] { if (this.detailView === "diff") return this.currentPatchLines(); const view = this.detailView; const task = this.currentTask(); const file = this.currentFile(); if (!task || !file) return []; if (this.isAnalysisLoading()) { const request = this.activeAnalysis!; const spinner = SPINNER_FRAMES[request.frame]!; const elapsed = Math.max(0, Math.floor((Date.now() - request.startedAt) / 1_000)); const status: DisplayLine = { kind: "message", text: `${spinner} Working... ${elapsed}s • D returns to diff and cancels`, }; return request.partial ? [status, { kind: "explanation", text: "" }, ...this.buildAnalysisLines(request.partial, width)] : [status, { kind: "message", text: "Waiting for the configured model…" }]; } if ( this.analysisError?.taskIndex === this.selectedTaskIndex && this.analysisError.fileIndex === this.selectedFileIndex && this.analysisError.view === view ) { const previous = this.analysisFor( this.selectedTaskIndex, this.selectedFileIndex, task, file, view, ); const label = view === "explanation" ? "Explanation" : "Rationale"; return [ { kind: "message", text: `${label} failed: ${this.analysisError.message}` }, { kind: "message", text: previous ? `Showing the previous ${label.toLowerCase()}. Press R to retry.` : "Press R to retry or D to return to the diff.", }, ...(previous ? [ { kind: "explanation" as const, text: "" }, ...this.buildAnalysisLines(previous, width), ] : []), ]; } const analysis = this.analysisFor( this.selectedTaskIndex, this.selectedFileIndex, task, file, view, ); if (!analysis) { const label = view === "explanation" ? "explanation" : "rationale"; return [{ kind: "message", text: `No ${label} is available for this file.` }]; } return this.buildAnalysisLines(analysis, width); } private currentPatchLines(): DisplayLine[] { const key = `${this.selectedTaskIndex}:${this.selectedFileIndex}`; const cached = this.patchCache.get(key); if (cached) return cached; const file = this.currentFile(); const lines = file ? this.buildPatchLines(file) : []; this.patchCache.set(key, lines); return lines; } private analysisKey( taskIndex: number, fileIndex: number, view: AnalysisView, ): string { return `${taskIndex}:${fileIndex}:${view}`; } private analysisFor( taskIndex: number, fileIndex: number, task: OverlayTask, file: FileChange, view: AnalysisView, ): string | undefined { return this.generatedAnalyses.get(this.analysisKey(taskIndex, fileIndex, view)) ?? this.getAnalysis(task, file, view); } private isAnalysisLoading(): boolean { return ( this.detailView !== "diff" && this.activeAnalysis?.taskIndex === this.selectedTaskIndex && this.activeAnalysis.fileIndex === this.selectedFileIndex && this.activeAnalysis.view === this.detailView ); } private buildAnalysisLines(analysis: string, width: number): DisplayLine[] { const lines: DisplayLine[] = []; for (const rawLine of analysis.split(/\r?\n/u)) { const logicalLine = sanitizeTerminalText(rawLine); const heading = /^(What changed|Behavior impact|Notable risks|Stated intent|Evidence from the changes|Likely rationale|Uncertainty):$/.test(logicalLine.trim()); for (const wrapped of wrapTextWithAnsi(logicalLine, Math.max(1, width))) { lines.push({ kind: heading ? "heading" : "explanation", text: wrapped }); } } return lines; } private buildPatchLines(file: FileChange): DisplayLine[] { if (file.binary) { return [{ kind: "message", text: "Binary file changed; no textual diff." }]; } if (file.patchOmitted) { return [{ kind: "message", text: omissionMessage(file.patchOmitted) }]; } if (file.patch === undefined) { return [{ kind: "message", text: "Line diff was not stored by this extension version." }]; } const parsed = parseUnifiedPatch(file.patch, MAX_FILE_PATCH_BYTES, MAX_PATCH_LINES); if (!parsed.valid) { return [{ kind: "message", text: "Stored diff data is invalid." }]; } if (parsed.lines.length === 0) { return [{ kind: "message", text: "No textual hunks; metadata-only change." }]; } let lineNumberWidth = 1; for (const line of parsed.lines) { lineNumberWidth = Math.max( lineNumberWidth, String(line.oldLine ?? 0).length, String(line.newLine ?? 0).length, ); } return parsed.lines.map((line) => { if (line.kind === "hunk") { return { kind: line.kind, text: sanitizeTerminalText(line.text) }; } if (line.kind === "note") { return { kind: line.kind, text: ` ${sanitizeTerminalText(line.text)}` }; } const oldLine = line.oldLine === undefined ? "" : String(line.oldLine); const newLine = line.newLine === undefined ? "" : String(line.newLine); const prefix = `${oldLine.padStart(lineNumberWidth)} ${newLine.padStart(lineNumberWidth)} │`; return { kind: line.kind, text: `${prefix}${sanitizeTerminalText(line.text)}` }; }); } private taskLine(taskIndex: number, width: number): string { const task = this.tasks[taskIndex]; if (!task) return " ".repeat(width); const selected = taskIndex === this.selectedTaskIndex; const marker = selected ? ">" : " "; const summary = task.summary; const content = this.fit( `${marker}${taskTime(task.timestamp)} ${summary.files.length}f +${summary.totalInsertions} -${summary.totalDeletions}`, width, ); if (selected) { const background = this.focus === "tasks" ? "selectedBg" : "toolPendingBg"; return this.theme.bg(background, this.theme.fg("text", content)); } return this.theme.fg("muted", content); } private fileLine(fileIndex: number, width: number): string { const file = this.currentSummary()?.files[fileIndex]; if (!file) return " ".repeat(width); const selected = fileIndex === this.selectedFileIndex; const marker = selected ? ">" : " "; const prefix = `${marker}${fileIcon(file.status)} `; const counts = fileCounts(file); const path = displayPath(file.file); const pathWidth = width - visibleWidth(prefix) - visibleWidth(counts) - 1; const content = pathWidth >= 4 ? `${prefix}${this.fit(path, pathWidth)} ${counts}` : this.fit(`${prefix}${path}`, width); if (selected) { const background = this.focus === "files" ? "selectedBg" : "toolPendingBg"; return this.theme.bg(background, this.theme.fg("text", content)); } return this.theme.fg("muted", content); } private detailLine(line: DisplayLine | undefined, width: number): string { if (!line) return " ".repeat(width); let themed: string; switch (line.kind) { case "added": themed = this.theme.fg("toolDiffAdded", line.text); break; case "deleted": themed = this.theme.fg("toolDiffRemoved", line.text); break; case "context": themed = this.theme.fg("toolDiffContext", line.text); break; case "hunk": case "heading": themed = this.theme.fg("accent", line.text); break; case "note": themed = this.theme.fg("dim", line.text); break; case "message": themed = this.theme.fg("muted", line.text); break; case "explanation": themed = this.theme.fg("text", line.text); break; } return this.fit(themed, width); } private paneHeader( taskWidth: number, fileWidth: number, detailWidth: number, ): string { return [ this.paneBoundary(0, "├"), this.paneHeaderSegment("TASKS", taskWidth, this.focus === "tasks"), this.paneBoundary(1, "┬"), this.paneHeaderSegment("FILES", fileWidth, this.focus === "files"), this.paneBoundary(2, "┬"), this.paneHeaderSegment("DETAIL", detailWidth, this.focus === "detail"), this.paneBoundary(3, "┤"), ].join(""); } private paneHeaderSegment(label: string, width: number, active: boolean): string { const displayedLabel = active ? `[ ${label} ]` : ` ${label} `; const fittedLabel = truncateToWidth(displayedLabel, width, ""); const remaining = Math.max(0, width - visibleWidth(fittedLabel)); const left = Math.floor(remaining / 2); const right = remaining - left; const segment = `${"─".repeat(left)}${fittedLabel}${"─".repeat(right)}`; return this.theme.fg(active ? "mdHeading" : "borderMuted", segment); } private paneSeparator( taskWidth: number, fileWidth: number, detailWidth: number, ): string { return [ this.paneBoundary(0, "├"), this.paneBorderSegment(taskWidth, "tasks"), this.paneBoundary(1, "┬"), this.paneBorderSegment(fileWidth, "files"), this.paneBoundary(2, "┬"), this.paneBorderSegment(detailWidth, "detail"), this.paneBoundary(3, "┤"), ].join(""); } private paneBorderSegment(width: number, pane: Focus): string { return this.theme.fg( pane === this.focus ? "mdHeading" : "border", "─".repeat(width), ); } private paneBoundary(position: 0 | 1 | 2 | 3, character: string): string { const active = this.focus === "tasks" ? position <= 1 : this.focus === "files" ? position >= 1 && position <= 2 : position >= 2; return this.theme.fg(active ? "mdHeading" : "border", character); } private fullHeaderRow( left: string, center: string, right: string, width: number, ): string { const fittedCenter = truncateToWidth(center, width, "…"); const centerWidth = visibleWidth(fittedCenter); let leftAreaWidth = Math.floor((width - centerWidth) / 2); let rightAreaWidth = Math.max(0, width - centerWidth - leftAreaWidth); const attributionShift = Math.min( 2, leftAreaWidth, Math.max(0, visibleWidth(right) - rightAreaWidth), ); leftAreaWidth -= attributionShift; rightAreaWidth += attributionShift; const fittedLeft = truncateToWidth(left, leftAreaWidth, "…"); const fittedRight = truncateLeftToWidth(right, rightAreaWidth); const leftPadding = " ".repeat( Math.max(0, leftAreaWidth - visibleWidth(fittedLeft)), ); const rightPadding = " ".repeat( Math.max(0, rightAreaWidth - visibleWidth(fittedRight)), ); return `${this.theme.fg("border", "│")}${fittedLeft}${leftPadding}${this.theme.fg("accent", fittedCenter)}${rightPadding}${this.theme.fg("muted", fittedRight)}${this.theme.fg("border", "│")}`; } private fullSplitRow(left: string, right: string, width: number): string { const rightLimit = Math.max(0, Math.floor(width * 0.5)); const fittedRight = truncateToWidth(right, rightLimit, "…"); const rightWidth = visibleWidth(fittedRight); const gap = rightWidth > 0 ? 1 : 0; const leftLimit = Math.max(0, width - rightWidth - gap); const fittedLeft = truncateToWidth(left, leftLimit, "…"); const padding = " ".repeat( Math.max(0, width - visibleWidth(fittedLeft) - rightWidth), ); return `${this.theme.fg("border", "│")}${fittedLeft}${padding}${fittedRight}${this.theme.fg("border", "│")}`; } private fit(content: string, width: number): string { if (width <= 0) return ""; const shortened = truncateToWidth(content, width, "…"); const padding = " ".repeat(Math.max(0, width - visibleWidth(shortened))); return `${shortened}${padding}`; } private stateKey(): string { return `${this.focus}:${this.detailView}:${this.selectedTaskIndex}:${this.selectedFileIndex}:${this.taskOffset}:${this.fileOffset}:${this.detailOffset}:${this.isAnalysisLoading()}:${this.analysisError?.taskIndex ?? ""}:${this.analysisError?.fileIndex ?? ""}:${this.analysisError?.view ?? ""}`; } }