import type { TUI } from "@earendil-works/pi-tui"; import { truncateToWidth } from "@earendil-works/pi-tui"; import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import type { SidebarState } from "./types.ts"; import { renderModelSection } from "./sections/model.js"; import { renderSessionSection } from "./sections/session.js"; import { renderContextSection } from "./sections/context.js"; import { renderGitSection } from "./sections/git.js"; import { renderHintSection } from "./sections/hint.js"; import { renderLocationSection } from "./sections/location.js"; import { padAnsi } from "./utils.js"; import { loadSidebarUIConfig, renderExternalPanels, type SidebarUIConfig, } from "./panels.js"; const SIDEBAR_WIDTH = 34; /** Minimum terminal columns required to show the sidebar. Below this, the sidebar is hidden. */ const MIN_TERMINAL_WIDTH = 120; const SIDEBAR_BG = "\x1b[48;2;0;0;0m"; const BG_RESET = "\x1b[49m"; const RESET_FG = "\x1b[39m"; // Use opencode sidebar's textMuted color (#808080 / rgb(128,128,128)) // for both dim and muted levels so secondary text is readable. const SIDEBAR_GRAY = "\x1b[38;2;128;128;128m"; /** * SidebarCompositor renders a right-sidebar by shrinking `terminal.columns` * (so Pi renders content in the reduced width) then painting the sidebar * region via raw ANSI escape codes after every Pi render cycle. * * This avoids Pi TUI overlay overlap because Pi never draws in the reserved * right-side columns. */ export class SidebarCompositor { private tui: TUI; private terminal: { columns: number; rows: number; write: (data: string) => void; }; private getState: () => SidebarState; private getCtx: () => ExtensionContext | undefined; private theme: Theme; private originalColumnsDesc: PropertyDescriptor | undefined; private originalDoRender: (() => void) | null = null; private disposed = false; private panelConfig: SidebarUIConfig = {}; constructor( tui: TUI, getState: () => SidebarState, getCtx: () => ExtensionContext | undefined, theme: Theme, ) { this.tui = tui; // TUI's internal terminal object: reach it via the TUI instance cast. this.terminal = ( tui as unknown as { terminal?: { columns: number; rows: number; write: (data: string) => void } } ).terminal ?? (tui as unknown as { columns: number; rows: number; write: (data: string) => void }); this.getState = getState; this.getCtx = getCtx; this.theme = theme; } install(): void { const self = this; // Shrink terminal.columns so Pi renders in the left portion this.originalColumnsDesc = this.describeProperty(this.terminal, "columns"); const origDesc = this.originalColumnsDesc; const terminal = this.terminal; Object.defineProperty(terminal, "columns", { configurable: true, enumerable: true, get() { const d = origDesc; const raw = d?.get ? (d.get.call(terminal) ?? 80) : typeof d?.value === "number" ? d.value : 80; // When terminal is too narrow, restore full width (sidebar hidden). // Also restore full width when the sidebar is disabled so Pi sees a // width change and fully redraws the screen, instead of leaving the // last painted sidebar frozen on the right. if (raw < MIN_TERMINAL_WIDTH || !self.getState().enabled) return raw; return Math.max(1, raw - SIDEBAR_WIDTH - 1); }, }); // Load external panel config from sidebar-ui.json const ctx = this.getCtx(); this.panelConfig = loadSidebarUIConfig(ctx?.cwd); // Hook tui.doRender so we paint the sidebar after every Pi render const tuiAny = this.tui as unknown as { doRender?: () => void }; if (typeof tuiAny.doRender === "function") { this.originalDoRender = tuiAny.doRender.bind(tuiAny); tuiAny.doRender = () => { if (self.disposed) { self.originalDoRender?.(); return; } self.originalDoRender!(); self.paint(); }; } } paint(): void { if (this.disposed) return; // Skip painting while TUI is stopped (e.g. external editor active) // to avoid ANSI codes overwriting the editor's display. // Read the TUI's own stopped state directly instead of hooking start/stop // which can be bypassed by other code paths (e.g. SIGCONT handler). if ((this.tui as unknown as { stopped?: boolean }).stopped) return; const state = this.getState(); if (!state.enabled) return; // Read raw terminal dims before the column shim const d = this.originalColumnsDesc; const rawCols = d?.get ? (d.get.call(this.terminal) ?? 80) : typeof d?.value === "number" ? d.value : 80; // Hide sidebar when terminal is too narrow if (rawCols < MIN_TERMINAL_WIDTH) return; const rawRows = this.terminal.rows ?? process.stdout.rows ?? 24; const sw = SIDEBAR_WIDTH; const sepCol = rawCols - sw; const sidebarCol = sepCol + 1; const ctx = this.getCtx(); const buffer = 1; const contentWidth = Math.max(8, sw - buffer); const innerWidth = Math.max(8, contentWidth - 3); const lines = this.buildSidebarContent(ctx, state, innerWidth, rawRows); // Atomic paint: sync + save/restore cursor let buf = "\x1b[?2026h"; // begin synchronized output buf += "\x1b7"; // save cursor (DECSC) buf += "\x1b[?7l"; // disable auto-wrap for (let row = 1; row <= rawRows; row++) { // Separator at the boundary between Pi content and sidebar buf += `\x1b[${row};${sepCol}H`; buf += this.theme.fg("border", row === 1 ? "\u2503" : "\u2502"); // Sidebar background + content buf += `\x1b[${row};${sidebarCol}H`; buf += SIDEBAR_BG; const line = lines[row - 1]; if (line !== undefined) { buf += truncateToWidth(line, sw, "", true); } else { buf += " ".repeat(sw); } buf += BG_RESET; } buf += "\x1b[?7h"; // enable auto-wrap buf += "\x1b8"; // restore cursor (DECRC) buf += "\x1b[?2026l"; // end synchronized output this.terminal.write(buf); } requestPiRender(): void { const tuiAny = this.tui as unknown as { requestRender?: (force?: boolean) => void; renderNow?: (force?: boolean) => void; }; if (typeof tuiAny.requestRender === "function") { tuiAny.requestRender(true); return; } tuiAny.renderNow?.(true); } private buildSidebarContent( ctx: ExtensionContext | undefined, state: SidebarState, innerWidth: number, rawRows: number, ): string[] { const lines: string[] = []; const fmtLine = (line: string) => padAnsi(truncateToWidth( this.theme.fg("borderMuted", "\u2502 ") + line, SIDEBAR_WIDTH, "", ), SIDEBAR_WIDTH); const add = (line = "") => lines.push(fmtLine(line)); const heading = (label: string) => { add(); add(this.theme.fg("text", this.theme.bold(label))); }; // Helper to build a section proxy with custom add/heading const makeSection = (a: typeof add, h: typeof heading) => ({ ctx, state, theme: this.theme, innerWidth, add: a, heading: h, muted: (s: string) => `${SIDEBAR_GRAY}${s}${RESET_FG}`, dim: (s: string) => `${SIDEBAR_GRAY}${s}${RESET_FG}`, options: { maxFiles: 12, buffer: 1, fillRows: 200, getThinkingLevel: () => state.getThinkingLevel(), }, }); // ── Top fixed sections ── renderSessionSection(makeSection(add, heading)); renderModelSection(makeSection(add, heading)); renderContextSection(makeSection(add, heading)); if (state.panelsCompact) { // ── Compact mode: budget panels so Git/Location/Hint always visible ── const topLines = lines.length; // Count bottom sections let bottomCount = 0; const countAdd: typeof add = () => { bottomCount++; }; const countHeading: typeof heading = () => { bottomCount += 2; }; const countSection = makeSection(countAdd, countHeading); renderGitSection(countSection); renderLocationSection(countSection); renderHintSection(countSection); // Budget for panels let panelBudget = rawRows - topLines - bottomCount; // Render panels within budget if (panelBudget > 0) { const panelAdd: typeof add = (line = "") => { if (panelBudget <= 0) return; panelBudget--; lines.push(fmtLine(line)); }; const panelHeading: typeof heading = (label: string) => { if (panelBudget < 2) return; panelAdd(); panelAdd(this.theme.fg("text", this.theme.bold(label))); }; renderExternalPanels(ctx, this.panelConfig, this.theme, innerWidth, panelAdd, panelHeading, { maxLines: 3, }); } // Bottom sections always rendered renderGitSection(makeSection(add, heading)); renderLocationSection(makeSection(add, heading)); renderHintSection(makeSection(add, heading)); } else { // ── Normal mode: render everything in order, no budget ── renderExternalPanels(ctx, this.panelConfig, this.theme, innerWidth, add, heading); renderGitSection(makeSection(add, heading)); renderLocationSection(makeSection(add, heading)); renderHintSection(makeSection(add, heading)); } return lines; } private describeProperty( obj: object, key: string, ): PropertyDescriptor | undefined { let target: object | null = obj; while (target) { const d = Object.getOwnPropertyDescriptor(target, key); if (d) return d; target = Object.getPrototypeOf(target); } return undefined; } dispose(): void { if (this.disposed) return; this.disposed = true; // Restore the original columns descriptor if (this.originalColumnsDesc) { Object.defineProperty(this.terminal, "columns", this.originalColumnsDesc); } else { try { Reflect.deleteProperty(this.terminal, "columns"); } catch { /* ignore */ } } // Restore the original doRender if (this.originalDoRender !== null) { (this.tui as unknown as { doRender?: () => void }).doRender = this.originalDoRender; } } }