import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type EditorTheme, type TUI, } from "@earendil-works/pi-tui"; type ModeState = { enabled: boolean; working: boolean; scrollMode: boolean; scrollOffset: number; lastPrompt: string; phase: string; currentWork: string; assistantText: string; assistantHistory: string[]; toolCounts: Record; errorCount: number; render?: () => void; spinner?: NodeJS.Timeout; resizeHandler?: () => void; }; const state: ModeState = { enabled: false, working: false, scrollMode: false, scrollOffset: 0, lastPrompt: "", phase: "idle", currentWork: "ready", assistantText: "", assistantHistory: [], toolCounts: {}, errorCount: 0, }; const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const RESPONSE_PANEL_TERMINAL_RATIO = 0.6; const RESPONSE_PANEL_MIN_BODY_LINES = 6; const RESPONSE_PANEL_RESERVED_ROWS = 12; function plainTextFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((part) => { if (part && typeof part === "object" && "type" in part && (part as { type: unknown }).type === "text") { return String((part as { text?: unknown }).text ?? ""); } return ""; }) .join(""); } function short(input: string, max = 96): string { return input.replace(/\s+/g, " ").trim().slice(0, max); } function repeat(char: string, count: number): string { return count <= 0 ? "" : char.repeat(count); } function padRightAnsi(text: string, width: number): string { return text + repeat(" ", Math.max(0, width - visibleWidth(text))); } function line(theme: any, width: number): string { return theme.fg("accent", truncateToWidth("─".repeat(Math.max(0, width)), width, "")); } function centered(text: string, width: number): string { const w = visibleWidth(text); const left = Math.max(0, Math.floor((width - w) / 2)); return truncateToWidth(repeat(" ", left) + text, width, ""); } function responseBodyHeight(): number { const rows = process.stdout.rows || 24; const target = Math.floor(rows * RESPONSE_PANEL_TERMINAL_RATIO) - 2; const available = Math.max(RESPONSE_PANEL_MIN_BODY_LINES, rows - RESPONSE_PANEL_RESERVED_ROWS); return Math.max(RESPONSE_PANEL_MIN_BODY_LINES, Math.min(target, available)); } function boxed(title: string, body: string[], width: number, theme: any): string[] { const inner = Math.max(10, width - 4); const titleText = ` ${title} `; const top = theme.fg("accent", `╭─${titleText}${repeat("─", Math.max(0, inner - visibleWidth(titleText)))}─╮`); const bottom = theme.fg("accent", `╰${repeat("─", inner + 2)}╯`); const rows = body.map((raw) => { const content = truncateToWidth(raw, inner, "…"); return theme.fg("accent", "│ ") + padRightAnsi(content, inner) + theme.fg("accent", " │"); }); return [truncateToWidth(top, width, ""), ...rows.map((row) => truncateToWidth(row, width, "")), truncateToWidth(bottom, width, "")]; } class Header implements Component { constructor(private theme: any) {} render(width: number): string[] { return [ centered(this.theme.fg("accent", "Pi Signal") + this.theme.fg("muted", " • simplified cockpit • /signal off restores native UI"), width), line(this.theme, width), ]; } invalidate() {} } class StatusPanel implements Component { constructor(private theme: any) {} render(width: number): string[] { const frame = state.working ? SPINNER[Math.floor(Date.now() / 120) % SPINNER.length] : "✓"; const statusColor = state.working ? "accent" : state.errorCount ? "warning" : "success"; const mode = state.scrollMode ? this.theme.fg("warning", "SCROLL") : this.theme.fg("muted", "input"); const counts = Object.entries(state.toolCounts) .sort(([a], [b]) => a.localeCompare(b)) .map(([name, count]) => `${name}:${count}`) .join(" ") || "tools:0"; const task = state.lastPrompt ? short(state.lastPrompt, Math.max(20, width - 22)) : "no active task"; return boxed( "status", [ `${this.theme.fg(statusColor, frame)} ${this.theme.fg("text", state.phase)} ${this.theme.fg("muted", "mode")} ${mode} ${this.theme.fg("muted", "errors")} ${state.errorCount}`, `${this.theme.fg("muted", "work")} ${this.theme.fg("text", short(state.currentWork || "ready", Math.max(20, width - 16)))}`, `${this.theme.fg("muted", "task")} ${this.theme.fg("text", task)}`, this.theme.fg("muted", counts), ], width, this.theme, ); } invalidate() {} } class ResponsePanel implements Component { constructor(private theme: any) {} render(width: number): string[] { const entries = [...state.assistantHistory]; if (state.assistantText.trim()) entries.push(state.assistantText); const source = entries.length ? entries.map((entry, index) => `${this.theme.fg("muted", `── response ${index + 1}/${entries.length} ──`)}\n${entry}`).join("\n\n") : "No AI response captured yet."; const wrapped = source .split("\n") .flatMap((part) => wrapTextWithAnsi(part || " ", Math.max(10, width - 6))); const height = responseBodyHeight(); const maxOffset = Math.max(0, wrapped.length - height); state.scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxOffset)); const start = state.scrollMode ? state.scrollOffset : maxOffset; const visible = wrapped.slice(start, start + height); while (visible.length < height) visible.push(""); const label = state.scrollMode ? `AI transcript (${start + 1}-${Math.min(wrapped.length, start + height)}/${wrapped.length} ctrl+x/esc exit)` : `AI transcript (${entries.length} responses • ${wrapped.length} lines • ctrl+x scroll)`; return boxed(label, visible.map((row) => this.theme.fg("text", row)), width, this.theme); } invalidate() {} } class Footer implements Component { constructor(private theme: any, private footerData: any) {} render(width: number): string[] { const branch = this.footerData.getGitBranch?.(); const left = state.scrollMode ? this.theme.fg("warning", "ctrl+x leader: scroll mode • j/k line • u/d page • g/G top/bottom") : this.theme.fg("dim", "ctrl+x scroll mode • /signal off restore"); const right = this.theme.fg("dim", branch ? `git:${branch}` : "pi-signal"); const gap = repeat(" ", Math.max(1, width - visibleWidth(left) - visibleWidth(right))); return [truncateToWidth(left + gap + right, width, "")]; } invalidate() {} } class PiModeEditor extends CustomEditor { constructor(tui: TUI, private editorTheme: EditorTheme, keybindings: any, private request: () => void) { super(tui, editorTheme, keybindings, { paddingX: 2 }); } handleInput(data: string): void { if (matchesKey(data, Key.ctrl("x"))) { state.scrollMode = !state.scrollMode; this.request(); return; } if (!state.scrollMode) { super.handleInput(data); return; } const page = Math.max(1, responseBodyHeight() - 1); if (data === "j" || matchesKey(data, Key.down)) state.scrollOffset += 1; else if (data === "k" || matchesKey(data, Key.up)) state.scrollOffset -= 1; else if (data === "d" || matchesKey(data, Key.pageDown)) state.scrollOffset += page; else if (data === "u" || matchesKey(data, Key.pageUp)) state.scrollOffset -= page; else if (data === "g" || matchesKey(data, Key.home)) state.scrollOffset = 0; else if (data === "G" || matchesKey(data, Key.end)) state.scrollOffset = Number.MAX_SAFE_INTEGER; else if (data === "i" || matchesKey(data, Key.escape)) state.scrollMode = false; else if (data.length !== 1 || data.charCodeAt(0) < 32) super.handleInput(data); this.request(); } render(width: number): string[] { const inner = Math.max(10, width - 2); const lines = super.render(inner); if (state.scrollMode && lines.length > 0) { const badge = " SCROLL "; const last = lines.length - 1; lines[last] = truncateToWidth(lines[last] ?? "", Math.max(0, inner - badge.length), "") + badge; } return lines.map((line, index) => { const content = padRightAnsi(truncateToWidth(line, inner, ""), inner); if (index === 0) return this.editorTheme.borderColor(`╭${content}╮`); if (index === lines.length - 1) return this.editorTheme.borderColor(`╰${content}╯`); return this.editorTheme.borderColor("│") + content + this.editorTheme.borderColor("│"); }); } } function startSpinner() { if (state.spinner) return; state.spinner = setInterval(() => state.render?.(), 120); } function stopSpinner() { if (!state.spinner) return; clearInterval(state.spinner); state.spinner = undefined; } function startResizeListener(render: () => void) { if (state.resizeHandler) process.stdout.off("resize", state.resizeHandler); state.resizeHandler = render; process.stdout.on("resize", state.resizeHandler); } function stopResizeListener() { if (!state.resizeHandler) return; process.stdout.off("resize", state.resizeHandler); state.resizeHandler = undefined; } function rerender() { state.render?.(); } function archiveAssistantText(text: string) { const archived = text.trimEnd(); if (!archived.trim()) return; if (state.assistantHistory[state.assistantHistory.length - 1] === archived) return; state.assistantHistory.push(archived); } function applyPiMode(ctx: ExtensionContext) { state.enabled = true; ctx.ui.setWorkingVisible(false); ctx.ui.setWorkingIndicator({ frames: [] }); ctx.ui.setToolsExpanded(false); ctx.ui.setHiddenThinkingLabel("thinking hidden"); ctx.ui.setHeader((_tui, theme) => new Header(theme)); ctx.ui.setWidget("signal-status", (tui, theme) => { state.render = () => tui.requestRender(); startResizeListener(state.render); return new StatusPanel(theme); }); ctx.ui.setWidget("signal-response", (tui, theme) => { state.render = () => tui.requestRender(); startResizeListener(state.render); return new ResponsePanel(theme); }, { placement: "belowEditor" }); ctx.ui.setFooter((tui, theme, footerData) => { const footer = new Footer(theme, footerData); const unsub = footerData.onBranchChange?.(() => tui.requestRender()); return { render: (width: number) => footer.render(width), invalidate: () => footer.invalidate(), dispose: unsub, }; }); ctx.ui.setEditorComponent((tui, theme, keybindings) => { state.render = () => tui.requestRender(); return new PiModeEditor(tui, theme, keybindings, () => tui.requestRender()); }); ctx.ui.setTitle("pi-signal"); rerender(); } function clearPiMode(ctx: ExtensionContext) { state.enabled = false; state.scrollMode = false; state.working = false; stopSpinner(); stopResizeListener(); ctx.ui.setWorkingVisible(true); ctx.ui.setWorkingIndicator(); ctx.ui.setHiddenThinkingLabel(); ctx.ui.setHeader(undefined); ctx.ui.setWidget("signal-status", undefined); ctx.ui.setWidget("signal-response", undefined, { placement: "belowEditor" }); ctx.ui.setFooter(undefined); ctx.ui.setEditorComponent(undefined); ctx.ui.setTitle("pi"); } export default function (pi: ExtensionAPI) { const installedExtensionPath = `${process.env.HOME ?? ""}/.pi/agent/git/github.com/accolver/pi-signal/extensions/signal.ts`; const currentExtensionPath = fileURLToPath(import.meta.url); if (existsSync(installedExtensionPath) && currentExtensionPath !== installedExtensionPath && currentExtensionPath.endsWith("/pi-signal/extensions/signal.ts")) { // Avoid duplicate `/signal:1` and `/signal:2` commands when developing from a checkout while the git package is also installed. return; } pi.registerCommand("signal", { description: "Enable the simplified Pi Signal TUI (/signal off to restore)", handler: async (args, ctx) => { const arg = args.trim().toLowerCase(); if (arg === "off" || arg === "disable") { clearPiMode(ctx); ctx.ui.notify("Pi Signal disabled", "info"); return; } if (arg === "toggle" && state.enabled) { clearPiMode(ctx); ctx.ui.notify("Pi Signal disabled", "info"); return; } applyPiMode(ctx); ctx.ui.notify("Pi Signal enabled", "info"); }, }); pi.on("before_agent_start", async (event) => { if (!state.enabled) return; archiveAssistantText(state.assistantText); state.lastPrompt = event.prompt; state.assistantText = ""; if (!state.scrollMode) state.scrollOffset = Number.MAX_SAFE_INTEGER; state.toolCounts = {}; state.errorCount = 0; state.phase = "starting"; state.currentWork = "preparing request"; rerender(); }); pi.on("agent_start", async () => { if (!state.enabled) return; state.working = true; state.phase = "working"; state.currentWork = "thinking"; startSpinner(); rerender(); }); pi.on("message_update", async (event) => { if (!state.enabled || event.message.role !== "assistant") return; state.phase = "responding"; state.currentWork = "writing response"; state.assistantText = plainTextFromContent(event.message.content); if (!state.scrollMode) state.scrollOffset = Number.MAX_SAFE_INTEGER; rerender(); }); pi.on("tool_execution_start", async (event) => { if (!state.enabled) return; state.phase = "tool"; state.currentWork = `${event.toolName} running`; state.toolCounts[event.toolName] = (state.toolCounts[event.toolName] ?? 0) + 1; rerender(); }); pi.on("tool_execution_update", async (event) => { if (!state.enabled) return; state.phase = "tool"; state.currentWork = `${event.toolName} updating`; rerender(); }); pi.on("tool_execution_end", async (event) => { if (!state.enabled) return; state.phase = event.isError ? "blocked" : "tool done"; state.currentWork = `${event.toolName} ${event.isError ? "failed" : "finished"}`; if (event.isError) state.errorCount += 1; rerender(); }); pi.on("message_end", async (event) => { if (!state.enabled || event.message.role !== "assistant") return; archiveAssistantText(plainTextFromContent(event.message.content) || state.assistantText); state.assistantText = ""; if (!state.scrollMode) state.scrollOffset = Number.MAX_SAFE_INTEGER; rerender(); }); pi.on("agent_end", async () => { if (!state.enabled) return; archiveAssistantText(state.assistantText); state.assistantText = ""; if (!state.scrollMode) state.scrollOffset = Number.MAX_SAFE_INTEGER; state.working = false; state.phase = state.errorCount ? "complete with errors" : "complete"; state.currentWork = "idle"; stopSpinner(); rerender(); }); pi.on("session_shutdown", async (_event, ctx) => { if (state.enabled) clearPiMode(ctx); stopSpinner(); }); }