/** * Input Frame (monkeypatch) — option B: a real box around the input * * pi's input area isn't a box: the pi-tui `Editor` draws two full-width * horizontal rules (top + bottom, NO side rails) around the text, colored by * `editor.borderColor`. Content lines carry no borders; when the input scrolls * a rule becomes a scroll indicator (`─── ↑ 3 more ───`), and an open * autocomplete/slash menu appends extra lines AFTER the bottom rule. * * There's no extension hook for the editor chrome, so — like user-prompt-frame — * we patch the editor's `render`. We target `CustomEditor` (the ONLY subclass of * Editor, and the class the main input is built from), so extension-input and * selector editors keep the plain native chrome. * * All chrome around the input itself is stripped: the TOP rule is dropped (no * header) and there are NO side rails. Only the BOTTOM survives, and as a plain * edge-to-edge rule (no ├┤ caps), so nothing around the input reads as a box. * The text itself is indented behind a ❯ prompt glyph, rhyming with the ❯ chip * that user-prompt-frame.ts stamps on the message once it is submitted: * ❯ your input text * that wraps here * ───────────────────── ← separator; sl0p-footer continues below * /slash menu below, borderless * * Exception: when the editor's top rule is a scroll indicator (`─── ↑ 3 more ───`) * it carries real information, so it is still drawn (set KEEP_SCROLL_INDICATOR * to false to suppress that too). * * Correctness notes: * - Content is laid out at (width - INDENT_W), NOT the full width, so the * editor's own word-wrap / lastWidth / cursor navigation agree with the * narrower column the text actually occupies. Passing the full width here * would make the editor wrap 2 columns too late and the indent would push * lines past the terminal edge. * - The indent is safe for the hardware cursor / IME marker (CURSOR_MARKER) * ONLY because it is prepended to the START of the line. The TUI derives the * cursor column as visibleWidth(line.slice(0, markerIndex)) from the FINAL * rendered line, so a prefix before the marker shifts the reported column by * exactly its own width — which is where the text now is. Never insert * anything after the marker, and never pad with a zero-width or * double-width glyph, or the cursor will desync from the text. * - Apart from that leading indent, content lines are passed through * untouched: no trailing padding, no truncation. * - The autocomplete/slash menu is deliberately NOT indented (see below). * - Scroll indicators keep their "↑/↓ N more" text; we round their end caps and * rebuild them at full width. * - The autocomplete/slash menu (lines after the bottom rule) is left exactly * as the editor produced it. * * Reuses the instance's own `borderColor`, so the box still reflects bash-mode / * thinking-level colors. Change the corner glyphs below. * * Safety: idempotent + reload-friendly, feature-detects, and try/catches to the * native full-width render on any error; restores the original on shutdown. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { CustomEditor } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; // ── Style knobs ───────────────────────────────────────────────────────────── // The top header rule is not drawn. Scroll indicators ("↑ N more") are kept // because they are information rather than chrome; set to false to drop them. const KEEP_SCROLL_INDICATOR = true; // Prompt glyph shown against the first visible input line; later lines get // blank space so wrapped text stays aligned. INDENT_W is the total gutter width // and MUST equal the visible width of what we actually emit (see cursor note // above). Set GLYPH to "" for a plain indent with no marker. const GLYPH = "❯"; const INDENT_W = 2; // The slash/autocomplete menu keeps its own left edge at column 0. It draws // full-width selection bars, so indenting it would shift those out of // alignment with the rule above them. Flip to true to indent it with the text. const INDENT_MENU = false; // ──────────────────────────────────────────────────────────────────────────── const H = "─"; const ORIG = Symbol.for("sl0p.inputFrame.origRender"); type Paint = (s: string) => string; function stripAnsi(s: string): string { // eslint-disable-next-line no-control-regex return s.replace(/\x1b\[[0-9;]*m/g, ""); } // A rule line is either a plain all-─ rule or a scroll indicator like // "─── ↑ 3 more ─────". Content and autocomplete rows are never rules. function classifyRule(line: string): "plain" | "indicator" | null { const t = stripAnsi(line); if (t.length < 4) return null; if (/^─+$/.test(t)) return "plain"; if (/^─+ [↑↓] \d+ more ─*$/.test(t)) return "indicator"; return null; } // The label to embed in a rule line. Scroll indicators keep their "↑/↓ N more" // text; plain rules get nothing. function borderLabel(line: string): string | undefined { if (classifyRule(line) === "indicator") { const m = stripAnsi(line).match(/[↑↓] \d+ more/); if (m) return ` ${m[0]} `; } return undefined; } // Build a full-width plain rule, optionally with a left-anchored label. No end // caps: the rule runs edge to edge so nothing reads as a box rail. function buildRule(width: number, label: string | undefined, paint: Paint): string { const L = H; const R = H; const inner = Math.max(0, width - 2); if (!label) return paint(L + H.repeat(inner) + R); const leftLen = 1; let content = label; const maxContent = Math.max(0, inner - leftLen - 1); if (visibleWidth(content) > maxContent) content = truncateToWidth(content, maxContent, "…"); const rightLen = Math.max(0, inner - leftLen - visibleWidth(content)); return paint(L + H.repeat(leftLen) + content + H.repeat(rightLen) + R); } export default function (pi: ExtensionAPI) { const Cls = CustomEditor as unknown as { prototype: { render?: (width: number) => string[]; borderColor?: Paint }; [ORIG]?: (width: number) => string[]; }; const proto: any = Cls.prototype; if (!proto || typeof proto.render !== "function") return; // Capture the true original once (resolves to Editor.prototype.render, which // CustomEditor inherits); always re-wrap from it so /reload never stacks. if (!(Cls as any)[ORIG]) (Cls as any)[ORIG] = proto.render; const orig = (Cls as any)[ORIG] as (width: number) => string[]; proto.render = function patchedRender(this: any, width: number): string[] { // Too narrow to restyle — fall back to the native chrome. if (!width || width < 12 + INDENT_W) return orig.call(this, width); let lines: string[]; try { // Lay out at the indented width so the editor's own wrapping matches the // column the text really gets. The rules it draws at this width are // discarded and rebuilt at full width below. lines = orig.call(this, width - INDENT_W); } catch { return orig.call(this, width); } try { if (!Array.isArray(lines) || lines.length < 2) return orig.call(this, width); const paint: Paint = typeof this.borderColor === "function" ? this.borderColor : (s) => s; // Locate the top rule (first) and bottom rule (second). Everything between // is input content; everything after is the autocomplete/slash menu. let topIdx = -1; let botIdx = -1; for (let i = 0; i < lines.length; i++) { if (classifyRule(lines[i])) { if (topIdx < 0) topIdx = i; else { botIdx = i; break; } } } if (topIdx < 0 || botIdx < 0) return orig.call(this, width); // unexpected shape const out: string[] = []; // No header: the top rule is dropped unless it's a scroll indicator. const topLabel = KEEP_SCROLL_INDICATOR ? borderLabel(lines[topIdx]) : undefined; if (topLabel) out.push(buildRule(width, topLabel, paint)); // Content rows: prompt glyph on the first, aligned blanks after. The // prefix goes at the head of the line so the cursor marker shifts with it. const glyphPad = " ".repeat(Math.max(0, INDENT_W - visibleWidth(GLYPH))); const gutter = GLYPH ? paint(GLYPH) + glyphPad : " ".repeat(INDENT_W); const contPad = " ".repeat(INDENT_W); // When the input is scrolled, the first VISIBLE line is not the first line // of the prompt, so the glyph would lie about where the input starts. A // scroll indicator in the top rule is exactly that condition. const scrolled = classifyRule(lines[topIdx]) === "indicator"; for (let i = topIdx + 1; i < botIdx; i++) { out.push((i === topIdx + 1 && !scrolled ? gutter : contPad) + lines[i]); } out.push(buildRule(width, borderLabel(lines[botIdx]), paint)); // Menu rows below the divider: exactly as the editor produced them. for (let i = botIdx + 1; i < lines.length; i++) { out.push(INDENT_MENU ? contPad + lines[i] : lines[i]); } return out; } catch { return orig.call(this, width); } }; pi.on("session_shutdown", () => { try { if ((Cls as any)[ORIG]) proto.render = (Cls as any)[ORIG]; } catch { /* ignore */ } }); }