import { existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { CustomEditor, keyText, type ExtensionAPI, type ExtensionContext, type ContextUsage, type KeybindingsManager, } from "@earendil-works/pi-coding-agent"; import { getCapabilities, hyperlink, truncateToWidth, visibleWidth, type Component, type EditorTheme, type TUI, } from "@earendil-works/pi-tui"; import { renderFixedEditorCluster } from "./fixed-editor/cluster.ts"; import { emergencyTerminalModeReset, TerminalSplitCompositor, } from "./fixed-editor/terminal-split.ts"; const RESET = "\x1b[0m"; const BOLD = "\x1b[1m"; const PI_LOGO = "⡯⢣"; // 4x4 braille rasterisation of the official Pi SVG mark. const DEEP_BLUE: Rgb = [59, 130, 246]; const BLUE: Rgb = [34, 211, 238]; const SKY: Rgb = [168, 85, 247]; const ICE: Rgb = [236, 72, 153]; const PALETTE: Rgb[] = [DEEP_BLUE, BLUE, SKY, ICE, SKY, BLUE]; const GRADIENT_ENABLED = process.env.PI_UI_GRADIENT === "1"; type Rgb = [number, number, number]; type ThinkingLevel = | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | string; const TITLE_LINES = [ "▀████████████▀", " ╘███ ███ ", " ███ ███ ", " ███ ███ ", " ▄███▄ ▄███▄ ", ]; const RUNCAT_FRAMES = ["", "", "", "", ""]; const RUNCAT_FRAME_MS = 167; function hasRunCatFont() { if (process.env.PI_UI_RUNCAT === "0") return false; if (process.env.PI_UI_RUNCAT === "1") return true; const fontPaths = [ path.join(homedir(), ".local", "share", "fonts", "runcat.ttf"), path.join(homedir(), ".fonts", "runcat.ttf"), path.join(homedir(), "Library", "Fonts", "runcat.ttf"), "/Library/Fonts/runcat.ttf", ...(process.env.LOCALAPPDATA ? [ path.join( process.env.LOCALAPPDATA, "Microsoft", "Windows", "Fonts", "runcat.ttf", ), ] : []), ...(process.env.WINDIR ? [path.join(process.env.WINDIR, "Fonts", "runcat.ttf")] : []), ]; return fontPaths.some((fontPath) => existsSync(fontPath)); } const canRenderRunCat = hasRunCatFont(); function mix(a: number, b: number, t: number) { return Math.round(a + (b - a) * t); } function xtermColor(index: number): Rgb | null { if (index < 0 || index > 255) return null; const ansi: Rgb[] = [ [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192], [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0], [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255], ]; if (index < 16) return ansi[index] ?? null; if (index < 232) { const value = index - 16; const levels = [0, 95, 135, 175, 215, 255]; return [ levels[Math.floor(value / 36)]!, levels[Math.floor((value % 36) / 6)]!, levels[value % 6]!, ]; } const gray = 8 + (index - 232) * 10; return [gray, gray, gray]; } function parseThemeColor(theme: any, token: string): Rgb | null { try { const colored = theme.fg(token, "X"); const truecolor = colored.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/); if (truecolor) { return [ Number(truecolor[1]), Number(truecolor[2]), Number(truecolor[3]), ]; } const indexed = colored.match(/\x1b\[38;5;(\d+)m/); return indexed ? xtermColor(Number(indexed[1])) : null; } catch { return null; } } function resolveThemePalette(theme: any): Rgb[] { if (!theme?.fg) return PALETTE; return [ parseThemeColor(theme, "accent") ?? DEEP_BLUE, parseThemeColor(theme, "borderAccent") ?? BLUE, parseThemeColor(theme, "warning") ?? SKY, parseThemeColor(theme, "mdHeading") ?? ICE, parseThemeColor(theme, "muted") ?? SKY, parseThemeColor(theme, "dim") ?? BLUE, ]; } function paletteForTheme(theme: any) { return resolveThemePalette(theme); } function sampleGradient(position: number, palette: Rgb[]) { const wrapped = ((position % 1) + 1) % 1; const scaled = wrapped * palette.length; const index = Math.floor(scaled); const nextIndex = (index + 1) % palette.length; const t = scaled - index; const a = palette[index]!; const b = palette[nextIndex]!; return [mix(a[0], b[0], t), mix(a[1], b[1], t), mix(a[2], b[2], t)] as Rgb; } function fg([r, g, b]: Rgb, text: string) { return `\x1b[38;2;${r};${g};${b}m${text}${RESET}`; } function stripAnsi(text: string) { return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); } function gradientText(text: string, phase: number, palette = PALETTE) { if (!GRADIENT_ENABLED) return fg(palette[0] ?? DEEP_BLUE, text); const chars = [...stripAnsi(text)]; const span = Math.max(chars.length - 1, 1); return chars .map((char, index) => char === " " ? char : fg(sampleGradient(index / span + phase, palette), char), ) .join(""); } function projectName(cwd: string) { return path.basename(cwd) || "session"; } function shortCwd(cwd: string) { const normalized = cwd.replace(/\\/g, "/").replace(/\/$/, "").toLowerCase(); if (normalized === "c:/windows/system32") return "~"; const home = process.env.USERPROFILE || process.env.HOME; return home && cwd.toLowerCase().startsWith(home.toLowerCase()) ? `~${cwd.slice(home.length)}` : cwd; } function formatTokens(count: number) { if (count < 1000) return count.toString(); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; return `${Math.round(count / 1000000)}M`; } function formatModel(modelId: string) { return modelId.replace(/^models\//, ""); } function providerLogo(providerId: string | undefined) { const id = providerId?.toLowerCase() ?? ""; if (id.includes("openai") || id.includes("codex")) return "󰚩"; if (id.includes("copilot") || id.includes("github")) return ""; if (id.includes("opencode")) return "󰅪"; return "◆"; } function renderPiLogo(palette = PALETTE) { if (!GRADIENT_ENABLED) return fg(palette[0] ?? DEEP_BLUE, PI_LOGO); return `${fg(palette[0] ?? DEEP_BLUE, PI_LOGO[0] ?? "")}${fg(palette[2] ?? SKY, PI_LOGO[1] ?? "")}`; } function contextMeterIcon(percent: number | null | undefined) { if (percent === null || percent === undefined) return "○"; const slices = ["󰪞", "󰪟", "󰪠", "󰪡", "󰪢", "󰪣", "󰪤", "󰪥"]; const index = Math.max( 0, Math.min(slices.length - 1, Math.ceil(percent / 12.5) - 1), ); return slices[index]; } function formatContext(usage: ContextUsage | undefined) { if (!usage || usage.percent === null) return "○ ?%/?"; const max = usage.contextWindow ?? 0; const maxText = max > 0 ? formatTokens(max) : "?"; return `${contextMeterIcon(usage.percent)} ${Math.round(usage.percent)}%/${maxText}`; } function formatPathSegment(cwd: string, theme: any) { const short = shortCwd(cwd).replace(/\\/g, "/"); let rendered: string; if (short === "~") { rendered = theme.fg("text", theme.bold(" ~")); } else { const slash = short.lastIndexOf("/"); if (slash === -1) rendered = `${theme.fg("dim", " ")}${theme.fg("text", theme.bold(short))}`; else { const parent = short.slice(0, slash + 1); const base = short.slice(slash + 1); rendered = `${theme.fg("dim", ` ${parent}`)}${theme.fg("text", theme.bold(base))}`; } } try { if (!getCapabilities().hyperlinks) return rendered; return hyperlink(rendered, pathToFileURL(path.resolve(cwd)).href); } catch { return rendered; } } function closeDanglingHyperlink(line: string) { const markerCount = (line.match(/\x1b\]8;;/g) ?? []).length; return markerCount % 2 === 1 ? `${line}\x1b]8;;\x1b\\` : line; } function contextColor(percent: number | null | undefined) { if (percent === null || percent === undefined) return "dim"; if (percent >= 85) return "error"; if (percent >= 65) return "warning"; return "muted"; } function thinkingColor(level: ThinkingLevel) { switch (level) { case "off": return "thinkingOff"; case "minimal": return "thinkingMinimal"; case "low": return "thinkingLow"; case "medium": return "thinkingMedium"; case "high": return "thinkingHigh"; case "xhigh": return "thinkingXhigh"; case "max": return "thinkingMax"; default: return "muted"; } } function fitSegments(width: number, segments: string[], separator = " ") { const budget = Math.max(0, width); let line = ""; for (const segment of segments) { if (!segment) continue; const prefix = line ? separator : ""; const remaining = budget - visibleWidth(line) - visibleWidth(prefix); if (remaining <= 0) break; if (visibleWidth(segment) <= remaining) { line += `${prefix}${segment}`; continue; } const truncated = truncateToWidth(segment, remaining, "…"); if (truncated) line += `${prefix}${closeDanglingHyperlink(truncated)}`; break; } return line; } /** * Render a one-line footer with the right-hand content taking priority. * The right segments are fitted first, so pinned context survives narrow * terminals before the left-side identity/location segments do. */ function splitLine(width: number, leftSegments: string[], rightSegments: string[]) { const budget = Math.max(1, width); const right = fitSegments(budget, rightSegments); if (!right) return fitSegments(budget, leftSegments); const gapWidth = 1; const leftBudget = Math.max(0, budget - visibleWidth(right) - gapWidth); const left = fitSegments(leftBudget, leftSegments); if (!left) return truncateToWidth(right, budget, "…"); const gap = Math.max(1, budget - visibleWidth(left) - visibleWidth(right)); return `${left}${" ".repeat(gap)}${right}`; } /** * Fit optional right-side details before a trailing segment that must always * remain visible at the terminal's right edge. */ function fitSegmentsBeforeTrailing( width: number, segments: string[], trailing: string, separator = " ", ) { const budget = Math.max(1, width); const fittedTrailing = fitSegments(budget, [trailing], separator); if (!fittedTrailing) return ""; const detailBudget = Math.max( 0, budget - visibleWidth(fittedTrailing) - visibleWidth(separator), ); const details = fitSegments(detailBudget, segments, separator); return details ? `${details}${separator}${fittedTrailing}` : fittedTrailing; } function splitLineWithPinnedRight( width: number, leftSegments: string[], rightSegments: string[], pinnedRight: string, ) { return splitLine( width, leftSegments, [fitSegmentsBeforeTrailing(width, rightSegments, pinnedRight)], ); } function formatShortcutText(raw: string) { return raw .split("/") .map((combo) => { const parts = combo.split("+").filter(Boolean); const formatted = parts.map((part) => { const lower = part.toLowerCase(); if (lower === "ctrl") return "Ctrl"; if (lower === "alt") return "Alt"; if (lower === "shift") return "⇧"; if (lower === "escape" || lower === "esc") return "Esc"; if (lower === "pageup") return "PageUp"; if (lower === "pagedown") return "PageDown"; if (part.length === 1) return part.toUpperCase(); return part.charAt(0).toUpperCase() + part.slice(1); }); return formatted[0] === "⇧" ? `⇧${formatted.slice(1).join("+")}` : formatted.join("+"); }) .join("/"); } function configuredShortcut( keybinding: Parameters[0], fallback: string, ) { try { const configured = formatShortcutText(keyText(keybinding)); return configured || fallback; } catch { return fallback; } } function padVisible(text: string, width: number) { const length = visibleWidth(text); if (length >= width) return truncateToWidth(text, width, ""); return `${text}${" ".repeat(width - length)}`; } function oneLine(text: string) { return text.replace(/\s+/g, " ").trim(); } function headerShortcutHints(): Array<[string, string]> { return [ [configuredShortcut("app.model.select", "Ctrl+L"), "model"], [configuredShortcut("app.thinking.cycle", "⇧Tab"), "thinking"], [configuredShortcut("app.tools.expand", "Ctrl+O"), "tools"], ["Alt+S", "stash"], [configuredShortcut("app.editor.external", "Ctrl+G"), "editor"], [configuredShortcut("app.message.dequeue", "Alt+Up"), "edit queue"], [configuredShortcut("app.message.followUp", "Alt+Enter"), "follow-up"], ]; } function headerShortcutBox(width: number, palette = PALETTE) { const shortcuts = headerShortcutHints(); const innerWidth = Math.max(12, width - 2); const title = `${BOLD}${fg(palette[2] ?? SKY, "shortcuts")}${RESET}`; const rows = shortcuts.slice(0, 4).map(([key, label], index) => { const next = shortcuts[index + 4]; const left = `${fg(palette[3] ?? ICE, key)} ${fg(palette[4] ?? SKY, label)}`; const right = next ? `${fg(palette[3] ?? ICE, next[0])} ${fg(palette[4] ?? SKY, next[1])}` : ""; const gap = Math.max( 2, innerWidth - visibleWidth(left) - visibleWidth(right), ); return `│${padVisible(`${left}${" ".repeat(gap)}${right}`, innerWidth)}│`; }); return [ `╭${"─".repeat(innerWidth)}╮`, `│${padVisible(title, innerWidth)}│`, ...rows, `╰${"─".repeat(innerWidth)}╯`, ]; } function renderHeader( width: number, phase: number, subtitleText: string, palette = PALETTE, terminalRows = Number.POSITIVE_INFINITY, ) { const compact = width < 72 || terminalRows <= 24; if (compact) { const compactText = stripAnsi( truncateToWidth(`π ${subtitleText}`, width, "…"), ); return [ "", `${BOLD}${gradientText(compactText, phase, palette)}${RESET}`, "", ]; } const logoWidth = Math.max(...TITLE_LINES.map((line) => visibleWidth(line))); const leftMargin = width >= 80 ? 2 : 0; const gap = 3; const minBoxWidth = 28; const maxBoxWidth = 48; const maxLogoBlockWidth = Math.max( logoWidth + 4, width - leftMargin - gap - minBoxWidth, ); const subtitleWidth = visibleWidth(` ${subtitleText}`); const logoBlockWidth = Math.max( logoWidth + 4, Math.min(maxLogoBlockWidth, subtitleWidth), ); const logoInset = Math.max(0, Math.floor((logoBlockWidth - logoWidth) / 2)); const canShowBox = width >= leftMargin + logoBlockWidth + gap + 24; const logoLines = [ ...TITLE_LINES.map((line, row) => gradientText( padVisible(`${" ".repeat(logoInset)}${line}`, logoBlockWidth), phase + row * 0.045, palette, ), ), `${BOLD}${gradientText(padVisible(stripAnsi(truncateToWidth(` ${subtitleText}`, logoBlockWidth, "…")), logoBlockWidth), phase + 0.18, palette)}${RESET}`, ]; const prefix = " ".repeat(leftMargin); if (!canShowBox) return [ "", ...logoLines.map((line) => truncateToWidth(`${prefix}${line}`, width, ""), ), "", ]; const boxWidth = Math.min( maxBoxWidth, Math.max(minBoxWidth, width - leftMargin - logoBlockWidth - gap), ); const boxLines = headerShortcutBox(boxWidth, palette); const rows = Math.max(logoLines.length, boxLines.length); const lines = []; for (let i = 0; i < rows; i++) { const logo = padVisible(logoLines[i] ?? "", logoBlockWidth); const box = boxLines[i] ?? ""; lines.push( truncateToWidth(`${prefix}${logo}${" ".repeat(gap)}${box}`, width, ""), ); } return ["", ...lines, ""]; } function formatDuration(ms: number) { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; if (minutes > 0) return `${minutes}m ${seconds}s`; return `${seconds}s`; } function renderActivityBorder( width: number, label: string, borderColor: (text: string) => string, theme: any, ) { const maxLabelWidth = Math.max(0, width - 4); const displayLabel = stripAnsi( truncateToWidth(` ${label} `, maxLabelWidth, "…"), ); const labelWidth = visibleWidth(displayLabel); const rails = width - labelWidth; if (rails < 4 || !displayLabel) return borderColor("─".repeat(Math.max(0, width))); const left = Math.floor(rails / 2); const right = rails - left; return `${borderColor("─".repeat(left))}${theme.fg("muted", displayLabel)}${borderColor("─".repeat(right))}`; } export default function (pi: ExtensionAPI) { let currentModelId = "no model selected"; let currentProviderId = ""; let currentThinking: ThinkingLevel = "?"; let requestHeaderRender: (() => void) | undefined; let requestFooterRender: (() => void) | undefined; let stashedEditorText: string | undefined; let currentEditor: CustomEditor | undefined; let fixedEditorCompositor: TerminalSplitCompositor | undefined; let fixedEditorEnabled = process.env.PI_UI_FIXED_EDITOR !== "0"; let editorGeneration = 0; let fixedEditorContainer: any; let fixedStatusContainer: any; let fixedWidgetContainerAbove: any; let fixedWidgetContainerBelow: any; let activeFooterTheme: any; let activeFooterData: any; let gitDirty = false; let gitPrLabel = ""; let gitPrRefreshedAt = 0; let gitDirtyInFlight: Promise | undefined; let gitDirtyQueued = false; let turnHadFileEdits = false; let uiActive = false; const deferredTimers = new Set>(); let lastUserMessage = ""; let workingStartedAt: number | undefined; let workingPhase = "Working"; let runningToolCalls = new Set(); let workingTimer: ReturnType | undefined; function installHeader(ctx: ExtensionContext) { ctx.ui.setHeader((tui) => { requestHeaderRender = () => tui.requestRender(); return { render(width: number) { return renderHeader( width, 0, `${formatModel(currentModelId)} · ${projectName(ctx.cwd)}`, paletteForTheme(ctx.ui.theme), Number((tui as any).terminal?.rows) || Number.POSITIVE_INFINITY, ); }, invalidate() { tui.requestRender(); }, } satisfies Component; }); ctx.ui.setTitle( `π ${projectName(ctx.cwd)} · ${formatModel(currentModelId)}`, ); } function renderLastUserMessageLine(theme: any, width: number) { if (!lastUserMessage) return undefined; const label = theme.fg("dim", "You › "); const messageWidth = Math.max(0, width - visibleWidth(label)); return `${label}${theme.fg("muted", truncateToWidth(lastUserMessage, messageWidth, "…"))}`; } function extensionStatusSegments(theme: any, footerData: any) { const statuses = footerData.getExtensionStatuses?.() as | ReadonlyMap | undefined; if (!statuses || statuses.size === 0) return []; return Array.from(statuses.entries()) .filter(([key, text]) => { const normalizedKey = key.toLowerCase(); const normalizedText = oneLine(String(text ?? "")).toLowerCase(); return ( !["extmgr", "orchestrator", "theme"].includes(normalizedKey) && !normalizedKey.startsWith("remote-pi:") && !normalizedKey.includes("caffeinate") && !normalizedKey.includes("display-awake") && !normalizedText.includes("display-awake") ); }) .sort(([left], [right]) => left.localeCompare(right)) .map(([, text]) => oneLine(String(text ?? ""))) .filter(Boolean) .map((text) => theme.fg("dim", text)); } function renderFooterLines( ctx: ExtensionContext, theme: any, footerData: any, width: number, ) { const lines = [renderFooterLine(ctx, theme, footerData, width)]; const lastUserLine = renderLastUserMessageLine(theme, width); if (lastUserLine) lines.push(lastUserLine); return lines; } function renderFooterLine( ctx: ExtensionContext, theme: any, footerData: any, width: number, ) { const usage = ctx.getContextUsage(); const branch = footerData.getGitBranch(); const context = formatContext(usage); const contextTheme = contextColor(usage?.percent); const palette = paletteForTheme(ctx.ui.theme); const leftSegments = [ renderPiLogo(palette), `${theme.fg("accent", providerLogo(currentProviderId))} ${theme.fg("muted", formatModel(currentModelId))}`, theme.fg(thinkingColor(currentThinking) as any, currentThinking), formatPathSegment(ctx.cwd, theme), ...(branch ? [ theme.fg( gitDirty ? "warning" : "success", ` ${branch}${gitDirty ? "*" : ""}${gitPrLabel}`, ), ] : []), ]; const rightSegments = extensionStatusSegments(theme, footerData); const contextSegment = theme.fg(contextTheme as any, context); return closeDanglingHyperlink( splitLineWithPinnedRight( width, leftSegments, rightSegments, contextSegment, ), ); } function defer(fn: () => void, ms: number): ReturnType { const timer = setTimeout(() => { deferredTimers.delete(timer); fn(); }, ms); deferredTimers.add(timer); return timer; } function clearDeferredTimers() { for (const timer of deferredTimers) { clearTimeout(timer); } deferredTimers.clear(); } async function refreshGitDirty(ctx: ExtensionContext) { if (gitDirtyInFlight) { gitDirtyQueued = true; return gitDirtyInFlight; } gitDirtyInFlight = (async () => { const shouldRefreshPr = Date.now() - gitPrRefreshedAt >= 60_000; const [statusResult, prResult] = await Promise.allSettled([ pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd, timeout: 3000, }), shouldRefreshPr ? pi.exec("gh", ["pr", "view", "--json", "number,state,url"], { cwd: ctx.cwd, timeout: 3000, }) : Promise.resolve(undefined), ]); gitDirty = statusResult.status === "fulfilled" && statusResult.value.code === 0 && Boolean(statusResult.value.stdout.trim()); if (shouldRefreshPr) gitPrLabel = ""; if ( shouldRefreshPr && prResult.status === "fulfilled" && prResult.value && prResult.value.code === 0 ) { try { const pr = JSON.parse(prResult.value.stdout) as { number?: number; state?: string; }; if (pr.number) gitPrLabel = ` PR #${pr.number}${pr.state && pr.state !== "OPEN" ? ` ${pr.state.toLowerCase()}` : ""}`; } catch { // gh may be unavailable or the current branch may have no PR. } } if (shouldRefreshPr) gitPrRefreshedAt = Date.now(); if (uiActive) requestFooterRender?.(); })().finally(() => { gitDirtyInFlight = undefined; if (gitDirtyQueued && uiActive) { gitDirtyQueued = false; void refreshGitDirty(ctx); } }); return gitDirtyInFlight; } function installFooter(ctx: ExtensionContext) { ctx.ui.setFooter((tui, theme, footerData) => { activeFooterTheme = theme; activeFooterData = footerData; requestFooterRender = () => tui.requestRender(); const disposeBranch = footerData.onBranchChange(() => { gitPrRefreshedAt = 0; void refreshGitDirty(ctx); tui.requestRender(); }); void refreshGitDirty(ctx); return { render(width: number) { return fixedEditorCompositor ? [] : renderFooterLines(ctx, theme, footerData, width); }, invalidate() { tui.requestRender(); }, dispose() { disposeBranch?.(); }, } satisfies Component & { dispose(): void }; }); } function teardownFixedEditor(options?: { resetExtendedKeyboardModes?: boolean; }) { const hadCompositor = fixedEditorCompositor !== undefined; fixedEditorCompositor?.dispose(options); if (!hadCompositor && options?.resetExtendedKeyboardModes) { try { process.stdout.write(emergencyTerminalModeReset()); } catch { // Best-effort terminal cleanup on shutdown. } } fixedEditorCompositor = undefined; fixedEditorContainer = undefined; fixedStatusContainer = undefined; fixedWidgetContainerAbove = undefined; fixedWidgetContainerBelow = undefined; } function renderFixedFooterLines(ctx: ExtensionContext, width: number) { if (!activeFooterData) return []; return renderFooterLines( ctx, activeFooterTheme ?? ctx.ui.theme, activeFooterData, width, ); } function findContainerWithChild( tui: any, child: any, ): { container: any; index: number } | null { const children = Array.isArray(tui?.children) ? tui.children : []; const index = children.findIndex( (candidate: any) => Array.isArray(candidate?.children) && candidate.children.includes(child), ); return index === -1 ? null : { container: children[index], index }; } function installFixedEditor(ctx: ExtensionContext, tui: TUI) { teardownFixedEditor(); if (!ctx.hasUI || !(tui as any)?.terminal?.write || !currentEditor) return; const editorContainerMatch = findContainerWithChild(tui, currentEditor); if (!editorContainerMatch) return; const tuiChildren = Array.isArray((tui as any).children) ? (tui as any).children : []; fixedEditorContainer = editorContainerMatch.container; const statusContainerCandidate = tuiChildren[editorContainerMatch.index - 2] ?? null; fixedStatusContainer = statusContainerCandidate && typeof statusContainerCandidate.render === "function" ? statusContainerCandidate : null; fixedWidgetContainerAbove = tuiChildren[editorContainerMatch.index - 1] ?? null; fixedWidgetContainerBelow = tuiChildren[editorContainerMatch.index + 1] ?? null; let compositor!: TerminalSplitCompositor; compositor = new TerminalSplitCompositor({ tui, terminal: (tui as any).terminal, mouseScroll: true, getShowHardwareCursor: () => typeof (tui as any).getShowHardwareCursor === "function" && (tui as any).getShowHardwareCursor(), renderCluster: (width, terminalRows) => renderFixedEditorCluster({ width, terminalRows, statusLines: [ ...(fixedStatusContainer ? compositor .renderHidden(fixedStatusContainer, width) .filter((line) => visibleWidth(line) > 0) : []), ...(fixedWidgetContainerAbove ? compositor .renderHidden(fixedWidgetContainerAbove, width) .filter((line) => visibleWidth(line) > 0) : []), ], topLines: renderFixedFooterLines(ctx, width), editorLines: fixedEditorContainer ? compositor.renderHidden(fixedEditorContainer, width) : [], secondaryLines: fixedWidgetContainerBelow ? compositor.renderHidden(fixedWidgetContainerBelow, width) : [], }), }); fixedEditorCompositor = compositor; if (fixedStatusContainer?.render) compositor.hideRenderable(fixedStatusContainer); if (fixedWidgetContainerAbove?.render) compositor.hideRenderable(fixedWidgetContainerAbove); compositor.hideRenderable(fixedEditorContainer); if (fixedWidgetContainerBelow?.render) compositor.hideRenderable(fixedWidgetContainerBelow); compositor.install(); (tui as any).requestRender?.(true); } function activityLabel() { const duration = formatDuration(Date.now() - (workingStartedAt ?? Date.now())); if (!canRenderRunCat) return `${workingPhase} · ${duration}`; const elapsed = Date.now() - (workingStartedAt ?? Date.now()); const frame = RUNCAT_FRAMES[ Math.floor(elapsed / RUNCAT_FRAME_MS) % RUNCAT_FRAMES.length ] ?? ""; return `${frame} ${workingPhase} · ${duration}`; } function installEditor(ctx: ExtensionContext) { const generation = editorGeneration; const previousFactory = ctx.ui.getEditorComponent?.(); const ownPreviousFactory = previousFactory && (previousFactory as any).__piSetupFixedEditor; const factory = ( tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, ) => { if (previousFactory && !ownPreviousFactory) return previousFactory(tui, theme, keybindings); class FixedPiEditor extends CustomEditor { constructor() { super(tui, theme, keybindings); currentEditor = this; defer(() => { if (fixedEditorEnabled && generation === editorGeneration) installFixedEditor(ctx, tui); }, 0); } borderColor = (text: string) => ctx.ui.theme.fg(thinkingColor(currentThinking) as any, text); render(width: number) { const lines = super.render(width); if (!workingStartedAt || !lines[0]) return lines; if (lines[0].includes("↑") || lines[0].includes("↓")) return lines; if (visibleWidth(lines[0]) !== width) return lines; lines[0] = renderActivityBorder( width, activityLabel(), (text) => this.borderColor(text), ctx.ui.theme, ); return lines; } } return new FixedPiEditor(); }; (factory as any).__piSetupFixedEditor = true; ctx.ui.setEditorComponent(factory); } function setWorkingPhase(phase: string) { if (workingPhase === phase) return; workingPhase = phase; requestFooterRender?.(); } function stopWorkingTimer() { if (workingTimer) { clearInterval(workingTimer); workingTimer = undefined; } workingStartedAt = undefined; runningToolCalls.clear(); } function refreshActivity() { requestFooterRender?.(); } function startWorkingTimer() { stopWorkingTimer(); workingPhase = "Thinking"; workingStartedAt = Date.now(); refreshActivity(); workingTimer = setInterval( refreshActivity, canRenderRunCat ? RUNCAT_FRAME_MS : 1000, ); } function installUi(ctx: ExtensionContext) { if (!ctx.hasUI) return; uiActive = true; workingPhase = "Working"; runningToolCalls.clear(); currentModelId = ctx.model?.id ?? currentModelId; currentProviderId = ctx.model?.provider ?? currentProviderId; currentThinking = (typeof (ctx as any).getThinkingLevel === "function" ? (ctx as any).getThinkingLevel() : undefined) ?? (typeof (pi as any).getThinkingLevel === "function" ? (pi as any).getThinkingLevel() : undefined) ?? currentThinking; defer(() => { const thinking = (typeof (ctx as any).getThinkingLevel === "function" ? (ctx as any).getThinkingLevel() : undefined) ?? (typeof (pi as any).getThinkingLevel === "function" ? (pi as any).getThinkingLevel() : undefined); if (thinking) { currentThinking = thinking; requestFooterRender?.(); } }, 0); editorGeneration++; teardownFixedEditor(); installHeader(ctx); installFooter(ctx); const usesActivityBorder = fixedEditorEnabled && ctx.mode === "tui"; if (usesActivityBorder) installEditor(ctx); else ctx.ui.setEditorComponent(undefined); // The activity border replaces Pi's working row while the fixed editor is active. ctx.ui.setWorkingVisible(!usesActivityBorder); if (usesActivityBorder) ctx.ui.setWorkingIndicator({ frames: [] }); else ctx.ui.setWorkingIndicator(); } pi.on("session_start", (_event, ctx) => { installUi(ctx); }); pi.on("model_select", (event) => { currentModelId = event.model.id; currentProviderId = event.model.provider ?? currentProviderId; requestHeaderRender?.(); requestFooterRender?.(); }); pi.on("thinking_level_select", (event) => { currentThinking = event.level; requestFooterRender?.(); }); pi.on("message_start", (event, ctx) => { if (event.message.role === "assistant" && ctx.hasUI && workingStartedAt) { setWorkingPhase("Thinking"); } }); pi.on("message_update", (event, ctx) => { if (!ctx.hasUI || !workingStartedAt || runningToolCalls.size > 0) return; switch (event.assistantMessageEvent.type) { case "thinking_start": case "thinking_delta": case "thinking_end": setWorkingPhase("Thinking"); break; case "text_start": case "text_delta": case "text_end": setWorkingPhase("Streaming"); break; case "toolcall_start": case "toolcall_delta": setWorkingPhase("Preparing tool"); break; } }); const FILE_EDIT_TOOLS = new Set(["write", "edit", "write_file", "edit_file", "patch", "bash"]); pi.on("tool_execution_start", (event, ctx) => { if (!ctx.hasUI || !workingStartedAt) return; runningToolCalls.add(event.toolCallId); if (FILE_EDIT_TOOLS.has(event.toolName ?? "")) { turnHadFileEdits = true; } const toolName = oneLine(event.toolName ?? "") || "tool"; setWorkingPhase( runningToolCalls.size === 1 ? `Running ${toolName}` : `Running ${runningToolCalls.size} tools`, ); }); pi.on("tool_execution_end", (event, ctx) => { if (!ctx.hasUI || !workingStartedAt) return; runningToolCalls.delete(event.toolCallId); if (runningToolCalls.size === 0) setWorkingPhase("Thinking"); else setWorkingPhase( runningToolCalls.size === 1 ? "Running 1 tool" : `Running ${runningToolCalls.size} tools`, ); }); pi.on("message_end", (_event, ctx) => { if (ctx.hasUI) requestFooterRender?.(); }); pi.on("session_compact", (_event, ctx) => { if (ctx.hasUI) requestFooterRender?.(); }); pi.on("session_tree", (_event, ctx) => { if (ctx.hasUI) requestFooterRender?.(); }); pi.on("input", (event, ctx) => { if (event.source !== "interactive") return; const text = oneLine(event.text ?? ""); if (!text) return; lastUserMessage = text; requestFooterRender?.(); if (ctx.hasUI && fixedEditorCompositor) { defer(() => requestFooterRender?.(), 0); } }); pi.on("agent_start", (_event, ctx) => { if (!ctx.hasUI) return; startWorkingTimer(); }); pi.on("turn_end", (_event, ctx) => { if (ctx.hasUI) { if (turnHadFileEdits) { turnHadFileEdits = false; void refreshGitDirty(ctx); } if (workingStartedAt && runningToolCalls.size === 0) setWorkingPhase("Thinking"); } requestFooterRender?.(); }); pi.on("agent_end", (_event, ctx) => { if (ctx.hasUI) stopWorkingTimer(); requestFooterRender?.(); }); pi.on("session_shutdown", (event, ctx) => { if (!ctx.hasUI) return; stopWorkingTimer(); uiActive = false; gitDirtyQueued = false; clearDeferredTimers(); ctx.ui.setWorkingVisible(true); ctx.ui.setHeader(undefined); editorGeneration++; teardownFixedEditor({ resetExtendedKeyboardModes: event.reason === "quit", }); ctx.ui.setEditorComponent(undefined); ctx.ui.setFooter(undefined); ctx.ui.setWorkingIndicator(); }); pi.registerShortcut("alt+s", { description: "Stash/restore editor text", handler: async (ctx) => { const text = ctx.ui.getEditorText(); if (text.trim()) { stashedEditorText = text; ctx.ui.setEditorText(""); ctx.ui.notify( "Editor stashed. Press Alt+S with an empty editor to restore.", "info", ); return; } if (stashedEditorText !== undefined) { ctx.ui.setEditorText(stashedEditorText); stashedEditorText = undefined; ctx.ui.notify("Editor stash restored", "info"); } else { ctx.ui.notify("Editor stash is empty", "info"); } }, }); pi.registerCommand("pi-ui", { description: "Enable the custom Pi UI, including the fixed editor", handler: async (_args, ctx) => { fixedEditorEnabled = true; installUi(ctx); ctx.ui.notify("Enhanced custom Pi UI enabled", "info"); }, }); pi.registerCommand("pi-ui-safe", { description: "Use the custom Pi header/footer without the fixed-editor compositor", handler: async (_args, ctx) => { fixedEditorEnabled = false; installUi(ctx); ctx.ui.notify("Safe custom Pi UI enabled", "info"); }, }); pi.registerCommand("pi-ui-builtin", { description: "Restore Pi's built-in header/footer for this session", handler: async (_args, ctx) => { stopWorkingTimer(); uiActive = false; gitDirtyQueued = false; clearDeferredTimers(); ctx.ui.setWorkingVisible(true); ctx.ui.setWorkingMessage(); editorGeneration++; teardownFixedEditor({ resetExtendedKeyboardModes: true }); ctx.ui.setHeader(undefined); ctx.ui.setEditorComponent(undefined); ctx.ui.setFooter(undefined); ctx.ui.setWorkingIndicator(); ctx.ui.notify("Built-in Pi UI restored", "info"); }, }); }