@pi-archimedes/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@pi-archimedes/core",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "description": "Core UI modules for pi-archimedes: editor, message, startup, thinking",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "main": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./bus": "./src/bus.ts",
16
+ "./chrome": "./src/chrome.ts",
17
+ "./text": "./src/text.ts",
18
+ "./color": "./src/color.ts",
19
+ "./config": "./src/config.ts"
20
+ },
21
+ "peerDependencies": {
22
+ "@earendil-works/pi-coding-agent": ">=0.1.0",
23
+ "@earendil-works/pi-tui": ">=0.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^6.0.0"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ }
33
+ }
package/src/bus.ts ADDED
@@ -0,0 +1,77 @@
1
+ // ── Bus (pub/sub via globalThis Symbol) ──────────────────────────────────────
2
+
3
+ const BUS_KEY = Symbol.for("archimedes:bus");
4
+ const QUEUE_KEY = Symbol.for("archimedes:busQueue");
5
+
6
+ interface Bus {
7
+ emit(event: string, payload: unknown): void;
8
+ on(event: string, listener: (payload: unknown) => void): () => void;
9
+ }
10
+
11
+ interface CostUpdatePayload {
12
+ source: string;
13
+ inputTokens?: number;
14
+ outputTokens?: number;
15
+ cacheReadTokens?: number;
16
+ cacheWriteTokens?: number;
17
+ cost?: number;
18
+ }
19
+
20
+ const g = globalThis as unknown as Record<symbol, unknown>;
21
+
22
+ function createBus(): Bus {
23
+ const listeners = new Map<string, Array<(payload: unknown) => void>>();
24
+
25
+ return {
26
+ emit(event: string, payload: unknown): void {
27
+ const subs = listeners.get(event);
28
+ if (subs) {
29
+ for (const fn of subs) {
30
+ try {
31
+ fn(payload);
32
+ } catch {
33
+ /* ignore listener errors */
34
+ }
35
+ }
36
+ }
37
+ },
38
+ on(event: string, listener: (payload: unknown) => void): () => void {
39
+ if (!listeners.has(event)) {
40
+ listeners.set(event, []);
41
+ }
42
+ const subs = listeners.get(event)!;
43
+ subs.push(listener);
44
+ return () => {
45
+ const idx = subs.indexOf(listener);
46
+ if (idx !== -1) subs.splice(idx, 1);
47
+ };
48
+ },
49
+ };
50
+ }
51
+
52
+ export function getBus(): Bus {
53
+ let bus = g[BUS_KEY] as Bus | undefined;
54
+ if (!bus) {
55
+ bus = createBus();
56
+ g[BUS_KEY] = bus;
57
+ }
58
+ return bus;
59
+ }
60
+
61
+ export function initBus(): void {
62
+ const bus = getBus();
63
+ // Flush queued events (if any were emitted before init)
64
+ const queue = g[QUEUE_KEY] as Array<{ event: string; payload: unknown }> | undefined;
65
+ if (queue && queue.length > 0) {
66
+ for (const { event, payload } of queue) {
67
+ bus.emit(event, payload);
68
+ }
69
+ g[QUEUE_KEY] = [];
70
+ }
71
+ }
72
+
73
+ export const Events = {
74
+ COST_UPDATE: "archimedes:cost_update",
75
+ } as const;
76
+
77
+ export type { CostUpdatePayload };
package/src/chrome.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ // ── ANSI constants ───────────────────────────────────────────────────────────
4
+
5
+ export const RESET = "\x1b[0m";
6
+
7
+ // ── Editor chrome ────────────────────────────────────────────────────────────
8
+
9
+ export const PI_STR = "> ";
10
+ export const PI_WIDTH = PI_STR.length;
11
+ export const PI_SYMBOL_COL = 2;
12
+
13
+ export const AUTOCOMPLETE_CURSOR = "›";
14
+ export const HINT_MARGIN_RIGHT = 3;
15
+ export const PAD_X = 1;
16
+
17
+ // ── Fallback ANSI codes (neutral near-black) ─────────────────────────────────
18
+
19
+ const FALLBACK_PANEL_BG = "\x1b[48;2;16;16;16m";
20
+ const FALLBACK_PANEL_EDGE = "\x1b[38;2;16;16;16m";
21
+ const FALLBACK_FG = "\x1b[38;2;74;74;74m"; // #4a4a4a structural gray
22
+
23
+ // ── Palette ──────────────────────────────────────────────────────────────────
24
+
25
+ export type ThemeBg = "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
26
+
27
+ export interface PanePalette {
28
+ panelBg: string;
29
+ panelEdge: string;
30
+ frame(text: string): string;
31
+ prefix(text: string): string;
32
+ time(text: string): string;
33
+ hint(text: string): string;
34
+ }
35
+
36
+ function tryGetBgAnsi(theme: Theme, key: ThemeBg): string | undefined {
37
+ try {
38
+ return (theme as any).getBgAnsi(key);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ function tryFg(theme: Theme, key: ThemeColor, text: string): string | undefined {
45
+ try {
46
+ return theme.fg(key, text);
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function safeThemeColor(theme: Theme, keys: ThemeColor[], text: string): string {
53
+ for (const k of keys) {
54
+ const res = tryFg(theme, k, text);
55
+ if (res !== undefined) return res;
56
+ }
57
+ return fgWrap(FALLBACK_FG, text);
58
+ }
59
+
60
+ /** Convert a 48;2 or 48;5 bg ANSI code to its fg equivalent (38;…). */
61
+ function bgToFgAnsi(bg: string): string {
62
+ if (bg.startsWith("\x1b[48;2;") || bg.startsWith("\x1b[48;5;")) {
63
+ return bg.replace("\x1b[48;", "\x1b[38;");
64
+ }
65
+ return FALLBACK_PANEL_EDGE;
66
+ }
67
+
68
+ function fgWrap(ansi: string, text: string): string {
69
+ return `${ansi}${text}${RESET}`;
70
+ }
71
+
72
+ export function resolvePalette(theme: Theme): PanePalette {
73
+ const panelBg =
74
+ tryGetBgAnsi(theme, "userMessageBg") ??
75
+ tryGetBgAnsi(theme, "customMessageBg") ??
76
+ FALLBACK_PANEL_BG;
77
+
78
+ return {
79
+ panelBg,
80
+ panelEdge: bgToFgAnsi(panelBg),
81
+ frame: (t) => safeThemeColor(theme, ["borderMuted", "border"], t),
82
+ prefix: (t) => safeThemeColor(theme, ["borderMuted", "border"], t),
83
+ time: (t) => safeThemeColor(theme, ["muted", "accent"], t),
84
+ hint: (t) => safeThemeColor(theme, ["dim", "muted"], t),
85
+ };
86
+ }
87
+
88
+ /** Set a bg color on the theme's internal bgColors map. */
89
+ export function setThemeBg(theme: Theme, key: ThemeBg, ansi: string): void {
90
+ (theme as any)?.bgColors?.set(key, ansi);
91
+ }
package/src/color.ts ADDED
@@ -0,0 +1,195 @@
1
+ // HSL / RGB / ANSI color utilities. Pure: no external imports.
2
+
3
+ export interface RGB {
4
+ r: number;
5
+ g: number;
6
+ b: number;
7
+ } // each 0..255 integer
8
+
9
+ export interface HSL {
10
+ h: number;
11
+ s: number;
12
+ l: number;
13
+ } // h: 0..360, s/l: 0..1
14
+
15
+ // xterm standard 16-color palette (ANSI codes 0..15).
16
+ // Source: https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit
17
+ const XTERM_16: readonly RGB[] = [
18
+ { r: 0x00, g: 0x00, b: 0x00 }, // 0 black
19
+ { r: 0x80, g: 0x00, b: 0x00 }, // 1 maroon
20
+ { r: 0x00, g: 0x80, b: 0x00 }, // 2 green
21
+ { r: 0x80, g: 0x80, b: 0x00 }, // 3 olive
22
+ { r: 0x00, g: 0x00, b: 0x80 }, // 4 navy
23
+ { r: 0x80, g: 0x80, b: 0x80 }, // 5 purple
24
+ { r: 0x00, g: 0x80, b: 0x80 }, // 6 teal
25
+ { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 silver
26
+ { r: 0x80, g: 0x80, b: 0x80 }, // 8 grey
27
+ { r: 0xff, g: 0x00, b: 0x00 }, // 9 red
28
+ { r: 0x00, g: 0xff, b: 0x00 }, // 10 lime
29
+ { r: 0xff, g: 0xff, b: 0x00 }, // 11 yellow
30
+ { r: 0x00, g: 0x00, b: 0xff }, // 12 blue
31
+ { r: 0xff, g: 0x00, b: 0xff }, // 13 fuchsia
32
+ { r: 0x00, g: 0xff, b: 0xff }, // 14 aqua
33
+ { r: 0xff, g: 0xff, b: 0xff }, // 15 white
34
+ ];
35
+
36
+ // 6x6x6 cube levels for ANSI 16..231.
37
+ const CUBE_LEVELS: readonly number[] = [0, 95, 135, 175, 215, 255];
38
+
39
+ const clamp = (v: number, lo: number, hi: number): number =>
40
+ v < lo ? lo : v > hi ? hi : v;
41
+
42
+ const toByte = (v: number): number => clamp(Math.round(v), 0, 255);
43
+
44
+ export function hexToRgb(hex: string): RGB {
45
+ let s = hex.trim();
46
+ if (s.startsWith("#")) s = s.slice(1);
47
+ if (s.length === 3) {
48
+ s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
49
+ }
50
+ if (s.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(s)) {
51
+ throw new Error(`Invalid hex color: ${hex}`);
52
+ }
53
+ const n = parseInt(s, 16);
54
+ return {
55
+ r: (n >> 16) & 0xff,
56
+ g: (n >> 8) & 0xff,
57
+ b: n & 0xff,
58
+ };
59
+ }
60
+
61
+ export function rgbToHex(rgb: RGB): string {
62
+ const hex = (v: number) => toByte(v).toString(16).padStart(2, "0");
63
+ return `#${hex(rgb.r)}${hex(rgb.g)}${hex(rgb.b)}`;
64
+ }
65
+
66
+ export function rgbToHsl(rgb: RGB): HSL {
67
+ const r = rgb.r / 255;
68
+ const g = rgb.g / 255;
69
+ const b = rgb.b / 255;
70
+ const max = Math.max(r, g, b);
71
+ const min = Math.min(r, g, b);
72
+ const l = (max + min) / 2;
73
+ let h = 0;
74
+ let s = 0;
75
+ const d = max - min;
76
+ if (d !== 0) {
77
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
78
+ switch (max) {
79
+ case r:
80
+ h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
81
+ break;
82
+ case g:
83
+ h = ((b - r) / d + 2) * 60;
84
+ break;
85
+ default:
86
+ h = ((r - g) / d + 4) * 60;
87
+ break;
88
+ }
89
+ }
90
+ return { h, s, l };
91
+ }
92
+
93
+ export function hslToRgb(hsl: HSL): RGB {
94
+ const { h, s, l } = hsl;
95
+ if (s === 0) {
96
+ const v = toByte(l * 255);
97
+ return { r: v, g: v, b: v };
98
+ }
99
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
100
+ const p = 2 * l - q;
101
+ const hk = (((h % 360) + 360) % 360) / 360;
102
+ const hue2rgb = (t: number): number => {
103
+ let tt = t;
104
+ if (tt < 0) tt += 1;
105
+ if (tt > 1) tt -= 1;
106
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
107
+ if (tt < 1 / 2) return q;
108
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
109
+ return p;
110
+ };
111
+ return {
112
+ r: toByte(hue2rgb(hk + 1 / 3) * 255),
113
+ g: toByte(hue2rgb(hk) * 255),
114
+ b: toByte(hue2rgb(hk - 1 / 3) * 255),
115
+ };
116
+ }
117
+
118
+ export function ansi256ToRgb(code: number): RGB {
119
+ const c = Math.floor(code);
120
+ if (c < 0 || c > 255) {
121
+ throw new Error(`ANSI 256 code out of range: ${code}`);
122
+ }
123
+ if (c < 16) {
124
+ return { ...XTERM_16[c] };
125
+ }
126
+ if (c < 232) {
127
+ const idx = c - 16;
128
+ const r = CUBE_LEVELS[Math.floor(idx / 36) % 6];
129
+ const g = CUBE_LEVELS[Math.floor(idx / 6) % 6];
130
+ const b = CUBE_LEVELS[idx % 6];
131
+ return { r, g, b };
132
+ }
133
+ const v = 8 + (c - 232) * 10;
134
+ return { r: v, g: v, b: v };
135
+ }
136
+
137
+ export function parseAnsiFgToRgb(ansi: string): RGB | null {
138
+ if (!ansi) return null;
139
+ // Match a leading CSI SGR sequence: ESC [ params m
140
+ const tc = /^\x1b\[38;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
141
+ if (tc) {
142
+ return {
143
+ r: clamp(parseInt(tc[1], 10), 0, 255),
144
+ g: clamp(parseInt(tc[2], 10), 0, 255),
145
+ b: clamp(parseInt(tc[3], 10), 0, 255),
146
+ };
147
+ }
148
+ const palette = /^\x1b\[38;5;(\d{1,3})m/.exec(ansi);
149
+ if (palette) {
150
+ const code = parseInt(palette[1], 10);
151
+ if (code < 0 || code > 255) return null;
152
+ return ansi256ToRgb(code);
153
+ }
154
+ return null;
155
+ }
156
+
157
+ export function deriveDimColor(
158
+ input: string | number,
159
+ anchorLightness: number,
160
+ saturationFactor?: number,
161
+ ): string {
162
+ const rgb = typeof input === "number" ? ansi256ToRgb(input) : hexToRgb(input);
163
+ const hsl = rgbToHsl(rgb);
164
+ const factor = saturationFactor ?? 0.5;
165
+ const target: HSL = {
166
+ h: hsl.h,
167
+ s: hsl.s * factor,
168
+ l: Math.min(hsl.l, anchorLightness),
169
+ };
170
+ return rgbToHex(hslToRgb(target));
171
+ }
172
+
173
+ export function rgbToTruecolorFg(rgb: RGB): string {
174
+ return `\x1b[38;2;${toByte(rgb.r)};${toByte(rgb.g)};${toByte(rgb.b)}m`;
175
+ }
176
+
177
+ // ── Color helpers (from utils/ansi.ts) ───────────────────────────────────────
178
+
179
+ export function gray(level: number, text: string): string {
180
+ const l = Math.max(0, Math.min(255, Math.floor(level)));
181
+ return `\x1b[38;2;${l};${l};${l}m${text}\x1b[0m`;
182
+ }
183
+
184
+ export function rgb(r: number, g: number, b: number, text: string): string {
185
+ return `\x1b[38;2;${Math.floor(r)};${Math.floor(g)};${Math.floor(b)}m${text}\x1b[0m`;
186
+ }
187
+
188
+ export function extractRgb(themed: string): [number, number, number] {
189
+ const m = themed.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
190
+ return m ? [+m[1], +m[2], +m[3]] : [100, 100, 100];
191
+ }
192
+
193
+ export function lerp(a: number, b: number, t: number): number {
194
+ return a + (b - a) * t;
195
+ }
package/src/config.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export const ANIMATION_STYLES = [
6
+ "diagonal",
7
+ "top-right",
8
+ "bottom-left",
9
+ "bottom-right",
10
+ "center-out",
11
+ "wave",
12
+ "horizontal",
13
+ "vertical",
14
+ "vertical-up",
15
+ ] as const;
16
+ export type AnimationStyle = (typeof ANIMATION_STYLES)[number];
17
+
18
+ export interface CoreConfig {
19
+ mutedTheme: boolean;
20
+ codeUnindent: boolean;
21
+ labelText: string;
22
+ labelColor: string;
23
+ animationStyle: AnimationStyle;
24
+ }
25
+
26
+ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
27
+
28
+ export const DEFAULT_CORE_CONFIG: CoreConfig = {
29
+ mutedTheme: false,
30
+ codeUnindent: true,
31
+ labelText: "Thinking...",
32
+ labelColor: "255,215,0",
33
+ animationStyle: "vertical-up",
34
+ };
35
+
36
+ export function loadCoreConfig(): CoreConfig {
37
+ if (existsSync(SETTINGS_PATH)) {
38
+ try {
39
+ const full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
40
+ return { ...DEFAULT_CORE_CONFIG, ...(full["archimedes.core"] ?? {}) };
41
+ } catch {
42
+ /* ignore corrupt file */
43
+ }
44
+ }
45
+ return DEFAULT_CORE_CONFIG;
46
+ }
47
+
48
+ export function saveCoreConfig(config: CoreConfig): void {
49
+ let full: Record<string, unknown> = {};
50
+ if (existsSync(SETTINGS_PATH)) {
51
+ try {
52
+ full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
53
+ } catch {
54
+ /* ignore corrupt file */
55
+ }
56
+ }
57
+ full["archimedes.core"] = config;
58
+ writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
59
+ }
@@ -0,0 +1,199 @@
1
+ import {
2
+ CustomEditor,
3
+ type Theme,
4
+ type KeybindingsManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type TUI,
8
+ type EditorTheme,
9
+ truncateToWidth,
10
+ isKeyRelease,
11
+ visibleWidth,
12
+ } from "@earendil-works/pi-tui";
13
+
14
+ import {
15
+ RESET,
16
+ PAD_X,
17
+ PI_STR,
18
+ PI_WIDTH,
19
+ PI_SYMBOL_COL,
20
+ AUTOCOMPLETE_CURSOR,
21
+ HINT_MARGIN_RIGHT,
22
+ resolvePalette,
23
+ } from "../chrome.js";
24
+ import { isParentBorder, formatKey } from "../text.js";
25
+
26
+ const DOUBLE_PRESS_WINDOW_MS = 500;
27
+
28
+ export class HephaestusEditor extends CustomEditor {
29
+ private readonly piKeybindings: KeybindingsManager;
30
+ private readonly getTheme: () => Theme;
31
+ private readonly isIdle: () => boolean;
32
+ private readonly shutdown: () => void;
33
+ private hintTimer: ReturnType<typeof setTimeout> | undefined;
34
+ private hintMessage: string | undefined;
35
+ private pendingQuitUntil = 0;
36
+
37
+ constructor(
38
+ tui: TUI,
39
+ editorTheme: EditorTheme,
40
+ keybindings: KeybindingsManager,
41
+ {
42
+ getTheme,
43
+ isIdle,
44
+ shutdown,
45
+ }: {
46
+ getTheme: () => Theme;
47
+ isIdle: () => boolean;
48
+ shutdown: () => void;
49
+ },
50
+ ) {
51
+ super(tui, editorTheme, keybindings);
52
+ this.piKeybindings = keybindings;
53
+ this.getTheme = getTheme;
54
+ this.isIdle = isIdle;
55
+ this.shutdown = shutdown;
56
+ }
57
+
58
+ // ── Quit hint ─────────────────────────────────────────────
59
+
60
+ private clearHint(resetWindow = true): void {
61
+ clearTimeout(this.hintTimer);
62
+ this.hintTimer = undefined;
63
+ this.hintMessage = undefined;
64
+ if (resetWindow) this.pendingQuitUntil = 0;
65
+ this.tui.requestRender();
66
+ }
67
+
68
+ private showHint(message: string): void {
69
+ this.clearHint(false);
70
+ this.hintMessage = message;
71
+ this.tui.requestRender();
72
+ this.hintTimer = setTimeout(() => {
73
+ this.hintMessage = undefined;
74
+ this.hintTimer = undefined;
75
+ this.pendingQuitUntil = 0;
76
+ this.tui.requestRender();
77
+ }, DOUBLE_PRESS_WINDOW_MS);
78
+ }
79
+
80
+ // ── Input ─────────────────────────────────────────────────
81
+
82
+ override handleInput(data: string): void {
83
+ if (isKeyRelease(data)) {
84
+ super.handleInput(data);
85
+ return;
86
+ }
87
+
88
+ if (!this.piKeybindings.matches(data, "app.clear")) {
89
+ this.clearHint();
90
+ super.handleInput(data);
91
+ return;
92
+ }
93
+
94
+ const now = Date.now();
95
+
96
+ if (this.getText().length > 0) {
97
+ this.clearHint();
98
+ this.pendingQuitUntil = now + DOUBLE_PRESS_WINDOW_MS;
99
+ this.setText("");
100
+ return;
101
+ }
102
+
103
+ if (!this.isIdle()) {
104
+ this.clearHint();
105
+ super.handleInput(data);
106
+ return;
107
+ }
108
+
109
+ if (this.pendingQuitUntil > 0 && now <= this.pendingQuitUntil) {
110
+ this.clearHint();
111
+ this.shutdown();
112
+ return;
113
+ }
114
+
115
+ this.pendingQuitUntil = now + DOUBLE_PRESS_WINDOW_MS;
116
+ this.showHint(
117
+ `${formatKey(this.piKeybindings.getKeys("app.clear")[0])} to quit`,
118
+ );
119
+ }
120
+
121
+ // ── Render ────────────────────────────────────────────────
122
+
123
+ override render(width: number): string[] {
124
+ try {
125
+ const p = resolvePalette(this.getTheme());
126
+ const cw = width - PAD_X * 2;
127
+ const inner = cw - 2;
128
+ const rightPad = 1;
129
+ const superLines = super.render(cw - PI_WIDTH - rightPad);
130
+
131
+ let bottomIdx = superLines.length - 1;
132
+ for (let i = superLines.length - 1; i >= 1; i--) {
133
+ if (isParentBorder(superLines[i]!)) {
134
+ bottomIdx = i;
135
+ }
136
+ }
137
+ const contentLines = superLines.slice(1, bottomIdx);
138
+ const autoLines = superLines.slice(bottomIdx + 1).map(
139
+ (line) =>
140
+ " ".repeat(PI_SYMBOL_COL) +
141
+ truncateToWidth(
142
+ line.replace("→", AUTOCOMPLETE_CURSOR),
143
+ cw - PI_SYMBOL_COL,
144
+ "",
145
+ true,
146
+ ),
147
+ );
148
+
149
+ const topLine =
150
+ p.frame("┌") + p.frame("─".repeat(inner)) + p.frame("┐");
151
+ const botLine =
152
+ p.frame("└") + p.frame("─".repeat(inner)) + p.frame("┘");
153
+
154
+ const piPrefix = p.prefix(PI_STR);
155
+
156
+ const midLines = contentLines.map((line, i) => {
157
+ if (i !== 0) {
158
+ return (
159
+ " ".repeat(PI_WIDTH) +
160
+ truncateToWidth(line, cw - PI_WIDTH, "", true)
161
+ );
162
+ }
163
+
164
+ if (this.hintMessage) {
165
+ const hint =
166
+ p.hint(this.hintMessage) + " ".repeat(HINT_MARGIN_RIGHT);
167
+ return (
168
+ piPrefix +
169
+ truncateToWidth(
170
+ line,
171
+ cw - PI_WIDTH - visibleWidth(hint),
172
+ "",
173
+ true,
174
+ ) +
175
+ hint
176
+ );
177
+ }
178
+ return piPrefix + truncateToWidth(line, cw - PI_WIDTH, "", true);
179
+ });
180
+
181
+ const spacer = autoLines.length > 0 ? [" ".repeat(cw)] : [];
182
+ const raw = [topLine, ...midLines, ...spacer, ...autoLines, botLine];
183
+
184
+ const pad = " ".repeat(PAD_X);
185
+ const wrap = (line: string): string => {
186
+ const patched = line.replaceAll(RESET, RESET + p.panelBg);
187
+ return p.panelBg + pad + patched + pad + RESET;
188
+ };
189
+
190
+ const topEdge = p.panelEdge + "▁".repeat(width) + RESET;
191
+ const botEdge = p.panelEdge + "▔".repeat(width) + RESET;
192
+
193
+ return [topEdge, ...raw.map(wrap), botEdge];
194
+ } catch (e) {
195
+ console.error("HephaestusEditor render error:", e);
196
+ throw e;
197
+ }
198
+ }
199
+ }