@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
312 lines (311 loc) • 11.2 kB
JavaScript
/**
* A braille dot canvas with a text overlay, rendered to ANSI lines.
*
* Each terminal cell is a 2×4 grid of braille dots (U+2800–U+28FF), which
* gives the graph 8× the resolution of plain block characters and — because
* a cell is roughly twice as tall as it is wide — dots that are close to
* square, so no aspect correction is needed. Edges and node blobs are drawn
* in dot space; glyphs, labels and the legend are written into a cell-level
* overlay that takes precedence when rendering.
*
* Every rendered line is exactly `cols` cells wide and built only from
* width-1 characters, so the shell's `truncateLine`/`displayWidth` pipeline
* measures it correctly.
*/
import { displayWidth } from "../render.js";
const RESET = "\x1b[0m";
// Braille bit for dot (x, y) inside a cell — x ∈ {0,1}, y ∈ {0..3}.
const DOT_BITS = [
[0x01, 0x08],
[0x02, 0x10],
[0x04, 0x20],
[0x40, 0x80],
];
let colorModeOverride = null;
/** Tests pin the mode; real terminals are sniffed from COLORTERM/TERM. */
export function setColorMode(mode) {
colorModeOverride = mode;
}
export function colorMode() {
if (colorModeOverride)
return colorModeOverride;
const colorterm = (process.env.COLORTERM || "").toLowerCase();
if (colorterm === "truecolor" || colorterm === "24bit")
return "truecolor";
const term = (process.env.TERM || "").toLowerCase();
if (term.includes("direct") || term.includes("truecolor"))
return "truecolor";
return "256";
}
export function hexToRgb(hex) {
const clean = hex.replace("#", "");
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean.padEnd(6, "0");
const value = Number.parseInt(full.slice(0, 6), 16);
if (!Number.isFinite(value))
return [127, 127, 127];
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
export function rgbToHex(r, g, b) {
const clampByte = (v) => Math.max(0, Math.min(255, Math.round(v)));
return `#${[clampByte(r), clampByte(g), clampByte(b)].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
}
/** Linear blend from `from` toward `to` by `t` in [0, 1]. */
export function blendHex(from, to, t) {
const a = hexToRgb(from);
const b = hexToRgb(to);
const k = Math.max(0, Math.min(1, t));
return rgbToHex(a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k);
}
/** Nearest xterm-256 index: 6×6×6 colour cube, or the grey ramp for greys. */
export function hexToAnsi256(hex) {
const [r, g, b] = hexToRgb(hex);
if (Math.abs(r - g) < 10 && Math.abs(g - b) < 10) {
if (r < 8)
return 16;
if (r > 248)
return 231;
return 232 + Math.round(((r - 8) / 247) * 23);
}
const level = (v) => Math.round((v / 255) * 5);
return 16 + 36 * level(r) + 6 * level(g) + level(b);
}
const sgrCache = new Map();
/** Foreground SGR for a hex colour in the active colour mode. */
export function hexToSgr(hex) {
const key = `${colorMode()}:${hex}`;
const cached = sgrCache.get(key);
if (cached)
return cached;
const [r, g, b] = hexToRgb(hex);
const sgr = colorMode() === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${hexToAnsi256(hex)}m`;
sgrCache.set(key, sgr);
return sgr;
}
export class BrailleCanvas {
cols;
rows;
masks;
zs;
colors;
overlay;
constructor(cols, rows) {
this.cols = cols;
this.rows = rows;
const cells = Math.max(0, cols * rows);
this.masks = new Uint8Array(cells);
this.zs = new Int16Array(cells).fill(-32768);
this.colors = new Array(cells);
this.overlay = new Array(cells);
}
get dotWidth() { return this.cols * 2; }
get dotHeight() { return this.rows * 4; }
cell(col, row) {
return row * this.cols + col;
}
setDot(x, y, color, z = 0) {
const dx = Math.round(x);
const dy = Math.round(y);
if (dx < 0 || dy < 0 || dx >= this.dotWidth || dy >= this.dotHeight)
return;
const col = dx >> 1;
const row = dy >> 2;
const idx = this.cell(col, row);
this.masks[idx] |= DOT_BITS[dy & 3][dx & 1];
if (z >= this.zs[idx]) {
this.zs[idx] = z;
this.colors[idx] = color;
}
}
/** Bresenham in dot space. */
line(x0, y0, x1, y1, color, opts = {}) {
let ax = Math.round(x0);
let ay = Math.round(y0);
const bx = Math.round(x1);
const by = Math.round(y1);
const ddx = Math.abs(bx - ax);
const ddy = -Math.abs(by - ay);
const sx = ax < bx ? 1 : -1;
const sy = ay < by ? 1 : -1;
let err = ddx + ddy;
let n = 0;
for (;;) {
if (!opts.dotted || n % 2 === 0)
this.setDot(ax, ay, color, opts.z ?? 0);
if (ax === bx && ay === by)
break;
const e2 = 2 * err;
if (e2 >= ddy) {
err += ddy;
ax += sx;
}
if (e2 <= ddx) {
err += ddx;
ay += sy;
}
n++;
if (n > 100000)
break;
}
}
/** Filled disc in dot space; radius 0 lights a single dot. */
disc(cx, cy, radius, color, z = 1) {
const r = Math.max(0, radius);
const r2 = r * r + 0.25;
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) {
const ddx = x - cx;
const ddy = y - cy;
if (ddx * ddx + ddy * ddy <= r2)
this.setDot(x, y, color, z);
}
}
}
/**
* True when the cells [col, col+width) on `row` hold no overlay text —
* and, with `avoidDots`, no braille dots either, so a label does not
* paint over somebody else's node or edge.
*/
isFree(col, row, width, avoidDots = false) {
if (row < 0 || row >= this.rows || col < 0 || col + width > this.cols)
return false;
for (let c = col; c < col + width; c++) {
const idx = this.cell(c, row);
if (this.overlay[idx])
return false;
if (avoidDots && this.masks[idx])
return false;
}
return true;
}
/**
* Write styled text into the overlay. Characters wider than one cell are
* replaced with `·` so the row stays exactly `cols` wide. Returns the number
* of cells written (0 when the text starts off-canvas).
*/
putText(col, row, text, sgr = "") {
if (row < 0 || row >= this.rows)
return 0;
let c = col;
let written = 0;
for (const raw of text) {
if (c >= this.cols)
break;
if (c >= 0) {
const width = displayWidth(raw);
const ch = width === 1 ? raw : width === 0 ? "" : "·";
if (ch) {
this.overlay[this.cell(c, row)] = { ch, sgr };
written++;
}
else {
continue;
}
}
c++;
}
return written;
}
/**
* Overlay a line that already carries ANSI styling, one cell per visible
* character. Each cell records the full attribute state in force at that
* point (everything since the last reset), so the renderer can switch
* styles cell by cell without leaking bold or colour into the neighbours.
*/
putStyled(col, row, styled) {
if (row < 0 || row >= this.rows)
return 0;
let c = col;
let written = 0;
let state = "";
let i = 0;
while (i < styled.length) {
if (styled[i] === "\x1b") {
const end = styled.indexOf("m", i);
if (end === -1)
break;
const seq = styled.slice(i, end + 1);
state = seq === "\x1b[0m" || seq === "\x1b[m" ? "" : state + seq;
i = end + 1;
continue;
}
const cp = styled.codePointAt(i) ?? 32;
const raw = String.fromCodePoint(cp);
i += raw.length;
if (c >= this.cols)
break;
if (c >= 0) {
const width = displayWidth(raw);
if (width === 0)
continue;
if (width === 2) {
// A wide glyph owns two cells: the glyph in the first, an empty
// marker in the second so nothing else draws there and the row
// still adds up to the right width. At the edge it becomes a dot.
if (c + 1 >= this.cols) {
this.overlay[this.cell(c, row)] = { ch: "·", sgr: state };
written++;
c++;
continue;
}
this.overlay[this.cell(c, row)] = { ch: raw, sgr: state };
this.overlay[this.cell(c + 1, row)] = { ch: "", sgr: state };
written += 2;
c += 2;
continue;
}
this.overlay[this.cell(c, row)] = { ch: raw, sgr: state };
written++;
}
c++;
}
return written;
}
/** Every cell in a rectangle is overlaid with a space, hiding dots underneath (pane backdrop). */
clearRect(col, row, width, height) {
for (let r = row; r < row + height; r++) {
for (let c = col; c < col + width; c++) {
if (r < 0 || r >= this.rows || c < 0 || c >= this.cols)
continue;
this.overlay[this.cell(c, r)] = { ch: " ", sgr: "" };
}
}
}
render() {
const lines = [];
for (let row = 0; row < this.rows; row++) {
let out = "";
let current = "";
let dirty = false;
for (let col = 0; col < this.cols; col++) {
const idx = this.cell(col, row);
const over = this.overlay[idx];
let ch;
let sgr;
if (over) {
ch = over.ch;
sgr = over.sgr;
}
else if (this.masks[idx]) {
ch = String.fromCharCode(0x2800 + this.masks[idx]);
sgr = hexToSgr(this.colors[idx] ?? "#7f8db3");
}
else {
ch = " ";
sgr = "";
}
if (sgr !== current) {
if (current || dirty)
out += RESET;
out += sgr;
current = sgr;
dirty = true;
}
out += ch;
}
if (current || dirty)
out += RESET;
lines.push(out);
}
return lines;
}
}