/** * input-revamp.ts * * Extension: Pisces Input Revamp * * Replaces Pi's default input editor with a framed prompt bar inspired by * @nerisma/pi-input-revamp. Adapted for the Pisces academic context: * * โ•ญโ”€ ๐Ÿ  Fall 2025 ยท Wk 7 ยท CS301 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 5.2% ยท $0.015 ยท 8.3K out โ”€โ•ฎ * โ”‚ โ€บ explain binary search trees โ”‚ * โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ T3 ยท $0.008 ยท OUT 4.1K โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ * * Techniques borrowed from pi-input-revamp by @sebastienservouze: * - fitRoundedBorder (left/right text in โ•ญโ”€โ•ฎ / โ•ฐโ”€โ•ฏ borders) * - ANSI brightness engine (lerpToWhite, shadeFgAnsi, truecolor + 256-color) * - Typing-speed whitening (WPM โ†’ border brightness, fast attack / slow release) * - Submit flash (non-empty โ†’ empty triggers a brief white pulse) * - Thinking VU-meter (โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ bars animated with a sinusoid per bar) * - Lazy tool-count from before_provider_request payload (wire truth) */ import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { isGradedModeActive } from "../graded-session"; import { getWorkspaceState } from "../workspace-detector"; // โ”€โ”€ ANSI-safe width utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // visibleWidth and truncateToWidth must account for wide characters (emoji, // CJK) that occupy 2 terminal columns per code point, not 1. Getting this // wrong by even 1 column causes Pi to crash with "Rendered line exceeds // terminal width". // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g; function codePointWidth(cp: number): number { if (cp < 0x1100) return 1; // Zero-width: combining marks, variation selectors, ZWJ, skin-tone modifiers if (cp === 0x200D) return 0; // ZWJ if (cp === 0xFE0F) return 0; // variation selector-16 if (cp >= 0x1F3FB && cp <= 0x1F3FF) return 0; // skin-tone modifiers if (cp >= 0x0300 && cp <= 0x036F) return 0; // combining diacritics // Wide (2 columns) if (cp <= 0x115F) return 2; // Hangul Jamo if (cp >= 0x2E80 && cp <= 0x303E) return 2; // CJK Radicals โ†’ CJK Symbols if (cp >= 0x3041 && cp <= 0x33BF) return 2; // Hiragana โ†’ CJK Compat if (cp >= 0x4E00 && cp <= 0xA4C6) return 2; // CJK Unified Ideographs if (cp >= 0xAC00 && cp <= 0xD7A3) return 2; // Hangul Syllables if (cp >= 0xF900 && cp <= 0xFAFF) return 2; // CJK Compat Ideographs if (cp >= 0xFE10 && cp <= 0xFE6B) return 2; // Vert. forms, Compat forms if (cp >= 0xFF01 && cp <= 0xFF60) return 2; // Fullwidth Forms if (cp >= 0xFFE0 && cp <= 0xFFE6) return 2; // Fullwidth Signs if (cp >= 0x1F004 && cp <= 0x1F004) return 2; // Mahjong if (cp >= 0x1F0CF && cp <= 0x1F0CF) return 2; // Playing card joker if (cp >= 0x1F200 && cp <= 0x1F251) return 2; // Enclosed ideographic supplement if (cp >= 0x1F300 && cp <= 0x1F6FF) return 2; // Misc symbols & pictographs (๐Ÿ  etc.) if (cp >= 0x1F900 && cp <= 0x1F9FF) return 2; // Supplemental symbols if (cp >= 0x20000 && cp <= 0x2FFFD) return 2; // CJK Ext B-G if (cp >= 0x30000 && cp <= 0x3FFFD) return 2; // CJK Ext G-H return 1; } function visibleWidth(str: string): number { const plain = str.replace(ANSI_RE, ""); let w = 0; for (const char of plain) { w += codePointWidth(char.codePointAt(0) ?? 0); } return w; } function truncateToWidth(str: string, maxWidth: number, suffix = ""): string { if (visibleWidth(str) <= maxWidth) return str; const target = Math.max(0, maxWidth - visibleWidth(suffix)); let w = 0; let out = ""; let i = 0; while (i < str.length) { // eslint-disable-next-line no-control-regex const m = str.slice(i).match(/^\x1b\[[0-9;]*[A-Za-z]/); if (m) { out += m[0]; i += m[0].length; } else { const cp = str.codePointAt(i) ?? 0; const cw = codePointWidth(cp); if (w + cw > target) break; const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair out += str.slice(i, i + charLen); w += cw; i += charLen; } } return out + suffix; } // โ”€โ”€ Border builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function fitRoundedBorder( left: string, right: string, width: number, color: (s: string) => string, top: boolean, ): string { if (width <= 0) return ""; if (width === 1) return color(top ? "โ•ญ" : "โ•ฐ"); const lc = top ? "โ•ญ" : "โ•ฐ"; const rc = top ? "โ•ฎ" : "โ•ฏ"; const fixedWidth = 4; // lc + pad + pad + rc const minGap = 3; let lText = left; let rText = right; while (fixedWidth + visibleWidth(lText) + visibleWidth(rText) + minGap > width && visibleWidth(rText) > 0) { rText = truncateToWidth(rText, Math.max(0, visibleWidth(rText) - 1)); } while (fixedWidth + visibleWidth(lText) + visibleWidth(rText) + minGap > width && visibleWidth(lText) > 0) { lText = truncateToWidth(lText, Math.max(0, visibleWidth(lText) - 1)); } const gapW = Math.max(0, width - fixedWidth - visibleWidth(lText) - visibleWidth(rText)); const fill = "โ”€".repeat(gapW); return `${color(lc)}${color("โ”€")}${lText}${color(fill)}${rText}${color("โ”€")}${color(rc)}`; } // โ”€โ”€ Color animation engine (truecolor + 256-color compatible) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Technique from pi-input-revamp: parse ANSI fg โ†’ RGB, shift in RGB space, // re-emit in the SAME mode (truecolor or 256) so animations are never a no-op // on 256-color terminals. const CUBE_LEVELS = [0, 95, 135, 175, 215, 255]; const ANSI16_RGB: [number, number, number][] = [ [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], ]; function ansi256ToRgb(n: number): [number, number, number] { if (n < 16) return ANSI16_RGB[n]; if (n >= 232) { const v = 8 + (n - 232) * 10; return [v, v, v]; } const c = n - 16; return [ CUBE_LEVELS[Math.floor(c / 36) % 6], CUBE_LEVELS[Math.floor(c / 6) % 6], CUBE_LEVELS[c % 6], ]; } function nearestCubeIndex(v: number): number { let best = 0, bestDist = Infinity; for (let i = 0; i < CUBE_LEVELS.length; i++) { const d = Math.abs(CUBE_LEVELS[i] - v); if (d < bestDist) { bestDist = d; best = i; } } return best; } function rgbTo256(r: number, g: number, b: number): number { const spread = Math.max(r, g, b) - Math.min(r, g, b); if (spread < 10) { const gray = Math.round(((r + g + b) / 3 - 8) / 10); return 232 + Math.max(0, Math.min(23, gray)); } return 16 + 36 * nearestCubeIndex(r) + 6 * nearestCubeIndex(g) + nearestCubeIndex(b); } function parseFgAnsi(ansi: string): { rgb: [number, number, number]; mode: "truecolor" | "256" } | null { // eslint-disable-next-line no-control-regex let m = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/); if (m) return { rgb: [+m[1], +m[2], +m[3]], mode: "truecolor" }; // eslint-disable-next-line no-control-regex m = ansi.match(/\x1b\[38;5;(\d+)m/); if (m) return { rgb: ansi256ToRgb(+m[1]), mode: "256" }; return null; } function shadeFgAnsi(baseAnsi: string, amount: number, text: string): string { const p = parseFgAnsi(baseAnsi); if (!p) return `${baseAnsi}${text}\x1b[39m`; const r = Math.max(0, Math.min(255, p.rgb[0] + amount)); const g = Math.max(0, Math.min(255, p.rgb[1] + amount)); const b = Math.max(0, Math.min(255, p.rgb[2] + amount)); const open = p.mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`; return `${open}${text}\x1b[39m`; } function lerpToWhite(baseAnsi: string, t: number, text: string): string { const p = parseFgAnsi(baseAnsi); if (!p) return `${baseAnsi}${text}\x1b[39m`; const k = Math.max(0, Math.min(1, t)); const mix = (c: number) => Math.round(c + (255 - c) * k); const [r, g, b] = [mix(p.rgb[0]), mix(p.rgb[1]), mix(p.rgb[2])]; const open = p.mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`; return `${open}${text}\x1b[39m`; } // โ”€โ”€ Thinking VU-meter animation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const THINKING_EXPRS = [ "tracing the algorithm...", "parsing the problem...", "checking complexity...", "scanning the edge cases...", "reviewing the approach...", "assembling the solution...", "verifying the logic...", "consulting the spec...", ]; const DEFAULT_TOOL_EXPR = "running a tool..."; function renderThinkingGlyphs( elapsed: number, shade: (s: string, amount: number) => string, pulseOffset: number, ): string { const bars = [..."โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ"]; let out = ""; for (let i = 0; i < 5; i++) { const t = (Math.sin(elapsed / 150 + i * 0.9) + 1) / 2; const lvl = Math.round(t * (bars.length - 1)); out += shade(bars[lvl], lvl * 6 + pulseOffset); } return out; } // โ”€โ”€ Session metrics helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return `${n}`; } function computeSessionMetrics(entries: readonly Record[]): { cost: number; output: number; cacheRead: number; input: number; } { let cost = 0, output = 0, cacheRead = 0, input = 0; for (const e of entries) { if (e["type"] !== "message") continue; const msg = e["message"] as Record | undefined; if (msg?.["role"] !== "assistant") continue; const usage = msg["usage"] as Record | undefined; if (!usage) continue; cost += (usage["cost"] as unknown as { total?: number } | undefined)?.total ?? 0; output += usage["output"] ?? 0; cacheRead += usage["cacheRead"] ?? 0; input += usage["input"] ?? 0; } return { cost, output, cacheRead, input }; } function computeLastTurnMetrics(entries: readonly Record[]): { cost: number; output: number; } | null { let lastUserIdx = -1; for (let i = entries.length - 1; i >= 0; i--) { const msg = (entries[i]["message"] as Record | undefined); if (entries[i]["type"] === "message" && msg?.["role"] === "user") { lastUserIdx = i; break; } } if (lastUserIdx === -1) return null; let cost = 0, output = 0; for (let i = lastUserIdx + 1; i < entries.length; i++) { const e = entries[i]; if (e["type"] !== "message") continue; const msg = e["message"] as Record | undefined; if (msg?.["role"] !== "assistant") continue; const usage = msg["usage"] as Record | undefined; if (!usage) continue; cost += (usage["cost"] as unknown as { total?: number } | undefined)?.total ?? 0; output += usage["output"] ?? 0; } return cost > 0 || output > 0 ? { cost, output } : null; } function getTurnCount(entries: readonly Record[]): number { let n = 0; for (const e of entries) { const msg = (e["message"] as Record | undefined); if (e["type"] === "message" && msg?.["role"] === "user") n++; } return n; } // โ”€โ”€ Tool wire capture โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let _wireTools: unknown[] | null = null; function findToolsArray(payload: unknown): unknown[] | null { if (!payload || typeof payload !== "object") return null; const p = payload as Record; const nested = (k: string) => (p[k] as Record | undefined)?.["tools"]; for (const c of [p["tools"], nested("body"), nested("request"), nested("params")]) { if (Array.isArray(c)) return c; } return null; } let _activeToolName: string | null = null; // โ”€โ”€ Typing animation constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const TYPING_WHITE_WPM = 300; const TYPING_WINDOW_MS = 1000; const TYPING_DELTA_CAP = 4; const TYPING_ATTACK = 0.2; const TYPING_RELEASE = 0.80; const TYPING_IDLE_MS = 150; const TYPING_MAX = 1.2; const PULSE_RELEASE = 0.95; // โ”€โ”€ Module-level editor context (set at session_start) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ interface EditorContext { pi: ExtensionAPI; // eslint-disable-next-line @typescript-eslint/no-explicit-any ctx: Record; isActive: boolean; } let _editorCtx: EditorContext | null = null; // โ”€โ”€ Status bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function buildStatusText(isActive: boolean): string { if (!isActive) return "๐Ÿ  Pisces ยท workspace inactive"; return `๐Ÿ  Pisces ยท ${isGradedModeActive() ? "graded mode" : "safe mode"}`; } function refreshStatus(): void { const ecx = _editorCtx; if (!ecx) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ui = ecx.ctx["ui"] as Record | undefined; ui?.["setStatus"]?.("pisces", buildStatusText(ecx.isActive)); } // โ”€โ”€ Custom editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class PiscesEditor extends CustomEditor { // Thinking animation private _thinkingTimer: ReturnType | undefined; private _wasThinking = false; private _animStart = 0; // Typing whitening private _inputTimer: ReturnType | undefined; private _wasPulsing = false; private _lastInputText = ""; private _keyEvents: { t: number; n: number }[] = []; private _typeIntensity = 0; private _lastKeyTime = 0; // Submit flash private _submitTimer: ReturnType | undefined; private _submitPulse = 0; // Metrics pulse private _metricTimer: ReturnType | undefined; private _metricPulse = 0; private _lastMetricSig = ""; // Last completed turn cache (persisted across renders) private _lastTurn: { cost: number; output: number; turnNum: number } | null = null; dispose() { this._stop(this._thinkingTimer); this._thinkingTimer = undefined; this._stop(this._inputTimer); this._inputTimer = undefined; this._stop(this._submitTimer); this._submitTimer = undefined; this._stop(this._metricTimer); this._metricTimer = undefined; } private _stop(t: ReturnType | undefined) { if (t !== undefined) clearInterval(t); } private _requestRender() { try { this.tui.requestRender(); } catch { /* editor may be detached */ } } render(width: number): string[] { const ecx = _editorCtx; if (!ecx) return super.render(width); const { ctx } = ecx; const thm = ctx["ui"]["theme"]; const now = Date.now(); // โ”€โ”€ Thinking animation state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const isThinking = !(ctx["isIdle"] as () => boolean)(); if (isThinking && !this._wasThinking) { this._animStart = now; if (!this._thinkingTimer) { this._thinkingTimer = setInterval(() => this._requestRender(), 50); } } else if (!isThinking && this._wasThinking) { this._stop(this._thinkingTimer); this._thinkingTimer = undefined; this._requestRender(); } this._wasThinking = isThinking; const isGraded = isGradedModeActive(); const borderKey = isGraded ? "warning" : "accent"; const accentAnsi = thm["getFgAnsi"](borderKey) as string; const accent = (s: string) => thm["fg"](borderKey, s) as string; const muted = (s: string) => thm["fg"]("muted", s) as string; const dim = (s: string) => thm["fg"]("dim", s) as string; // โ”€โ”€ Typing speed โ†’ border whitening โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const currentText = this.getText(); if (currentText !== this._lastInputText) { const delta = currentText.length - this._lastInputText.length; if (this._lastInputText !== "" && currentText === "") { this._submitPulse = 1.0; if (!this._submitTimer) { this._submitTimer = setInterval(() => { this._submitPulse *= PULSE_RELEASE; if (this._submitPulse < 0.01) { this._submitPulse = 0; this._stop(this._submitTimer); this._submitTimer = undefined; } this._requestRender(); }, 16); } } this._lastInputText = currentText; if (delta > 0) { this._keyEvents.push({ t: now, n: Math.min(delta, TYPING_DELTA_CAP) }); this._lastKeyTime = now; } } this._keyEvents = this._keyEvents.filter(e => now - e.t < TYPING_WINDOW_MS); const charsInWindow = this._keyEvents.reduce((s, e) => s + e.n, 0); const wpm = (charsInWindow / 5) * (60000 / TYPING_WINDOW_MS); const targetIntensity = Math.min(TYPING_MAX, wpm / TYPING_WHITE_WPM); if (now - this._lastKeyTime > TYPING_IDLE_MS) { this._typeIntensity *= TYPING_RELEASE; } else { this._typeIntensity += (targetIntensity - this._typeIntensity) * TYPING_ATTACK; } if (this._typeIntensity < 0.01) this._typeIntensity = 0; const isPulsing = this._typeIntensity > 0 || this._keyEvents.length > 0; if (isPulsing && !this._wasPulsing) { if (!this._inputTimer) this._inputTimer = setInterval(() => this._requestRender(), 50); } else if (!isPulsing && this._wasPulsing) { this._stop(this._inputTimer); this._inputTimer = undefined; this._requestRender(); } this._wasPulsing = isPulsing; // โ”€โ”€ Border color (typing + submit combined) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const typeT = this._typeIntensity > 0.001 ? Math.max(0, Math.min(1, this._typeIntensity + Math.sin(now / 70) * 0.12 * this._typeIntensity)) : 0; const borderT = Math.max(typeT, this._submitPulse); const borderColor = (s: string): string => borderT > 0.001 ? lerpToWhite(accentAnsi, borderT, s) : accent(s); // โ”€โ”€ Session metrics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // eslint-disable-next-line @typescript-eslint/no-explicit-any let entries: readonly Record[] = []; let sessionMetrics = { cost: 0, output: 0, cacheRead: 0, input: 0 }; try { entries = (ctx["sessionManager"]?.["getEntries"]?.() ?? []) as readonly Record[]; sessionMetrics = computeSessionMetrics(entries); } catch { /* no session manager */ } const sig = `${sessionMetrics.cost}|${sessionMetrics.output}`; if (sessionMetrics.cost > 0 && sig !== this._lastMetricSig) { this._lastMetricSig = sig; this._metricPulse = 1.0; if (!this._metricTimer) { this._metricTimer = setInterval(() => { this._metricPulse *= PULSE_RELEASE; if (this._metricPulse < 0.01) { this._metricPulse = 0; this._stop(this._metricTimer); this._metricTimer = undefined; } this._requestRender(); }, 16); } } const mp = this._metricPulse; const pulsed = (ansi: string, text: string) => mp > 0.001 ? lerpToWhite(ansi, mp, text) : `${ansi}${text}\x1b[39m`; const warningAnsi = thm["getFgAnsi"]("warning") as string; const dimAnsi = thm["getFgAnsi"]("dim") as string; // โ”€โ”€ Thinking oscillation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const thinkElapsed = now - this._animStart; const thinkOffset = isThinking ? Math.round(Math.sin(now / 120) * 75) : 0; // โ”€โ”€ Top-left: ๐Ÿ  mode label โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const modeLabel = ecx.isActive ? (isGraded ? "Pisces ยท graded" : "Pisces ยท active") : "Pisces"; const leftText = ` ${muted("๐Ÿ ")} ${muted(modeLabel)} `; // โ”€โ”€ Top-right: context% ยท cost ยท tokens out โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let rightText = ""; if (sessionMetrics.cost > 0) { const rParts: string[] = []; try { const usage = (ctx["getContextUsage"] as (() => { percent?: number | null }) | undefined)?.(); if (usage?.percent != null) { rParts.push(pulsed(dimAnsi, `${usage.percent.toFixed(1)}%`)); } } catch { /* no context usage */ } rParts.push(pulsed(warningAnsi, `$${sessionMetrics.cost.toFixed(3)}`)); if (sessionMetrics.output > 0) { rParts.push(pulsed(dimAnsi, `${formatTokens(sessionMetrics.output)} out`)); } rightText = ` ${rParts.join(dim(" ยท "))} `; } // โ”€โ”€ Build render output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const result: string[] = []; // Thinking line (VU-meter + expression) if (isThinking) { const expr = _activeToolName ? DEFAULT_TOOL_EXPR : THINKING_EXPRS[Math.floor(thinkElapsed / 2000) % THINKING_EXPRS.length]; const glyphs = renderThinkingGlyphs( thinkElapsed, (s, amount) => shadeFgAnsi(accentAnsi, amount, s), thinkOffset, ); const animLine = ` ${glyphs} ${muted(expr)}`; result.push(animLine + " ".repeat(Math.max(0, width - visibleWidth(animLine)))); result.push(""); } // Top border result.push(fitRoundedBorder(leftText, rightText, width, borderColor, true)); // Content (word-wrapped, cursor, autocomplete) const innerWidth = Math.max(2, width - 2); const promptWidth = 3; // " โ€บ " = 3 columns const layoutWidth = Math.max(1, innerWidth - promptWidth); const maxTextWidth = innerWidth - promptWidth; // eslint-disable-next-line @typescript-eslint/no-explicit-any const layoutLines = (this as any)["layoutText"](layoutWidth) as Array<{ text: string; hasCursor: boolean; cursorPos?: number; }>; for (let i = 0; i < layoutLines.length; i++) { const ll = layoutLines[i]; let displayText = ll.text; let lineWidth = visibleWidth(displayText); if (ll.hasCursor && ll.cursorPos !== undefined) { const before = displayText.slice(0, ll.cursorPos); const after = displayText.slice(ll.cursorPos); if (after.length > 0) { displayText = before + `\x1b[7m${after[0]}\x1b[0m` + after.slice(1); } else { displayText = before + "\x1b[7m \x1b[0m"; lineWidth += 1; } } if (lineWidth > maxTextWidth) { displayText = truncateToWidth(displayText, maxTextWidth); lineWidth = maxTextWidth; } const promptGlyph = i === 0 ? borderColor("โ€บ") : " "; const prefix = ` ${promptGlyph} `; const padding = " ".repeat(Math.max(0, innerWidth - promptWidth - lineWidth)); result.push(borderColor("โ”‚") + prefix + displayText + padding + borderColor("โ”‚")); } // Autocomplete dropdown // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((this as any)["autocompleteState"] && (this as any)["autocompleteList"]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const autoLines = ((this as any)["autocompleteList"]["render"](innerWidth)) as string[]; for (const line of autoLines) { let l = line; let lw = visibleWidth(l); // Pi's internal autocomplete renderer doesn't account for wide chars (emoji, // CJK) so lines can exceed innerWidth display columns. Clamp before adding // border chars to prevent the "Rendered line exceeds terminal width" crash. if (lw > innerWidth) { const clipped = truncateToWidth(l, innerWidth); l = clipped + "\x1b[0m"; lw = visibleWidth(clipped); } const pad = " ".repeat(Math.max(0, innerWidth - lw)); result.push(borderColor("โ”‚") + l + pad + borderColor("โ”‚")); } } // Bottom border with last-turn stats let bottomRight = ""; try { const lastTurn = computeLastTurnMetrics(entries); if (lastTurn) { const turnNum = getTurnCount(entries); this._lastTurn = { cost: lastTurn.cost, output: lastTurn.output, turnNum }; } if (this._lastTurn) { const lt = this._lastTurn; const bParts: string[] = []; bParts.push(pulsed(accentAnsi, `T${lt.turnNum}`)); if (lt.cost > 0) bParts.push(pulsed(warningAnsi, `$${lt.cost.toFixed(3)}`)); if (lt.output > 0) bParts.push(pulsed(dimAnsi, `OUT ${formatTokens(lt.output)}`)); if (bParts.length > 1) bottomRight = ` ${bParts.join(dim(" ยท "))} `; } } catch { /* no entries */ } result.push(fitRoundedBorder("", bottomRight, width, borderColor, false)); return result; } } // โ”€โ”€ Extension entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export default function (pi: ExtensionAPI): void { pi.on("before_provider_request", (event) => { _wireTools = findToolsArray(event.payload); refreshStatus(); }); pi.on("tool_execution_start", (event) => { _activeToolName = event.toolName; }); pi.on("tool_execution_end", () => { _activeToolName = null; }); pi.on("session_start", (_event, ctx) => { const { isActive } = getWorkspaceState(); _editorCtx = { pi, ctx: ctx as unknown as Record, isActive }; refreshStatus(); ctx.ui.setWorkingVisible(false); // Hide the default footer โ€” all session info is shown in the border ctx.ui.setFooter(() => ({ render() { return []; }, invalidate() {}, })); ctx.ui.setEditorComponent((tui, theme, keybindings) => new PiscesEditor(tui, theme, keybindings, { paddingX: 0 }) ); }); // Fallback: report wire tool count when needed void _wireTools; // suppress unused-var lint }