@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
730 lines (729 loc) • 36.7 kB
JavaScript
/**
* Renders the terminal graph view: a braille force-graph canvas with a
* details pane beside it (or a strip beneath it on narrow terminals).
*
* Mirrors the web viewer's layout — canvas left, contents pane right — and
* its colour language: nodes take their topic/kind colour, the selection is
* amber, search dims everything that does not match.
*/
import { ACCENT_AMBER, ACCENT_CYAN, BG_COLOR, KIND_COLORS } from "../../graph-core/types.js";
import { nodeRank } from "../../graph-core/model.js";
import { displayWidth, padToWidth, style } from "../render.js";
import { BrailleCanvas, blendHex, hexToSgr } from "./canvas.js";
import { graphGlyphs } from "./glyphs.js";
import { MASCOT_COLOR, MASCOT_SPARK } from "./mascot.js";
import { BUBBLE_CHROME_COLS, BUBBLE_CHROME_ROWS, LIVE_BUBBLE_MS, bubbleRows, placeBubble } from "./bubble.js";
import { formatAge } from "./watch.js";
/**
* The pane takes a share of a wide terminal rather than a fixed 34 columns:
* a finding wraps into far fewer lines at 50 cells than at 32, which is the
* difference between reading it and reading the first third of it.
*/
export function paneWidthFor(width) {
return Math.max(34, Math.min(58, Math.round(width * 0.36)));
}
const WIDE_BREAKPOINT = 100;
/** Ceiling for the narrow-terminal detail strip; it only takes what it fills. */
const NARROW_STRIP_MAX = 6;
const DIM_TARGET = "#3a3f55";
/** Resolved per frame so PHREN_ICONS is honoured without a restart. */
let KIND_GLYPH = graphGlyphs();
const KIND_LABEL = {
project: "project",
finding: "finding",
task: "task",
entity: "fragment",
reference: "reference",
topic: "topic",
note: "note",
other: "node",
};
const HEALTH_COLOR = {
healthy: "#3ae374",
decaying: "#ffb648",
stale: "#ff5470",
};
function sgr(hex, extra = "") {
return `${hexToSgr(hex)}${extra}`;
}
function colored(hex, text) {
return `${hexToSgr(hex)}${text}\x1b[0m`;
}
/** Word-wrap plain text to `width` cells, at most `maxLines` lines, ellipsised. */
export function wrapText(text, width, maxLines) {
const words = text.split(/\s+/).filter(Boolean);
const lines = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (displayWidth(candidate) <= width) {
current = candidate;
continue;
}
if (current)
lines.push(current);
current = displayWidth(word) > width ? `${sliceWidth(word, width - 1)}…` : word;
if (lines.length >= maxLines)
break;
}
if (current && lines.length < maxLines)
lines.push(current);
if (lines.length > maxLines)
lines.length = maxLines;
const complete = lines.join(" ") === words.join(" ");
if (lines.length === maxLines && !complete && !lines[maxLines - 1].endsWith("…")) {
const last = lines[maxLines - 1];
lines[maxLines - 1] = displayWidth(last) >= width ? `${sliceWidth(last, width - 1)}…` : `${last}…`;
}
return lines;
}
function sliceWidth(text, width) {
let out = "";
let used = 0;
for (const ch of text) {
const w = displayWidth(ch);
if (used + w > width)
break;
out += ch;
used += w;
}
return out;
}
/** Braille radius for a node from its viewer size. */
function dotRadius(node, selected) {
const base = node.kind === "project" ? 3 : node.size >= 16 ? 2 : node.size >= 10 ? 1 : 0;
return selected ? base + 1 : base;
}
export function renderGraphView(controller, width, height) {
KIND_GLYPH = graphGlyphs();
if (controller.status === "loading" || controller.status === "idle")
return centered(width, height, [
colored(ACCENT_CYAN, "⠿ building your knowledge graph…"),
style.dim("scanning findings, tasks, fragments and references"),
]);
if (controller.status === "error")
return centered(width, height, [
style.boldRed("graph build failed"),
style.dim(controller.errorText || "unknown error"),
style.dim("press r to retry"),
]);
if (controller.status === "empty")
return centered(width, height, [
style.bold("nothing to draw yet"),
style.dim("add a project and some findings, then come back"),
]);
const wide = width >= WIDE_BREAKPOINT;
const selected = controller.selectedId ? controller.model.nodeById.get(controller.selectedId) ?? null : null;
// Build the strip first so it takes only the rows it fills; every row it
// gives back goes to the graph, which is the part worth the space.
const strip = !wide && selected ? renderStrip(controller, selected, width, NARROW_STRIP_MAX) : [];
const paneWidth = paneWidthFor(width);
const canvasCols = wide ? width - paneWidth - 1 : width;
const canvasRows = Math.max(4, height - strip.length);
const canvasLines = drawCanvas(controller, canvasCols, canvasRows, selected);
if (wide) {
const pane = renderPane(controller, selected, paneWidth, canvasRows);
const divider = style.dim("│");
return canvasLines.map((line, i) => `${line}${divider}${padToWidth(pane[i] ?? "", paneWidth)}`);
}
return [...canvasLines, ...strip.map((line) => padToWidth(line, width))];
}
function centered(width, height, lines) {
const out = [];
const top = Math.max(0, Math.floor((height - lines.length) / 2));
for (let i = 0; i < height; i++) {
const line = lines[i - top];
if (!line) {
out.push(" ".repeat(width));
continue;
}
const pad = Math.max(0, Math.floor((width - displayWidth(line)) / 2));
out.push(padToWidth(" ".repeat(pad) + line, width));
}
return out;
}
function drawCanvas(controller, cols, rows, selected) {
const canvas = new BrailleCanvas(cols, rows);
controller.setViewport(canvas.dotWidth, canvas.dotHeight);
const searching = Boolean(controller.search.query);
const matches = controller.search.matchIds;
const placed = [];
const projected = new Map();
for (const node of controller.visible.nodes) {
const d = controller.projectNode(node.id);
if (!d)
continue;
projected.set(node.id, d);
placed.push({ node, x: d.x, y: d.y, t: d.t });
}
const isLit = (id) => !searching || matches.has(id);
// How connected the selection is, so a hub's highlight stays readable.
const selectedDegree = selected ? (controller.model.visibleAdjacency.get(selected.id)?.size ?? 0) : 0;
// Edges first so node dots win the cell colour.
for (const link of controller.visible.links) {
const a = projected.get(link.source);
const b = projected.get(link.target);
if (!a || !b)
continue;
const touchesSelected = selected !== null && (link.source === selected.id || link.target === selected.id);
const color = edgeColor(controller, link, touchesSelected, isLit(link.source) && isLit(link.target), selectedDegree);
const depth = (a.t + b.t) / 2;
canvas.line(a.x, a.y, b.x, b.y, depth > 0 ? blendHex(color, BG_COLOR, depth * 0.7) : color, { z: touchesSelected ? 2 : 0, dotted: link.kind === "contradicts" });
}
const watching = controller.watchEnabled && controller.watch.running;
const heatOf = (id) => (watching ? controller.watch.heatOf(id) : 0);
for (const { node, x, y, t } of placed) {
const isSelected = selected?.id === node.id;
const heat = heatOf(node.id);
let color = node.baseColor;
if (isSelected)
color = ACCENT_AMBER;
else if (searching && !matches.has(node.id))
color = blendHex(node.baseColor, DIM_TARGET, 0.7);
// A just-touched node pulses toward the live cyan and swells a little.
if (heat > 0)
color = blendHex(color, ACCENT_CYAN, 0.3 + heat * 0.55);
// In orbit the far side fades and shrinks; that is what reads as depth.
if (t > 0)
color = blendHex(color, BG_COLOR, t * 0.7);
const radius = Math.max(0, dotRadius(node, isSelected) + (heat > 0.35 ? 1 : 0) - (t > 0.5 ? 1 : 0));
canvas.disc(x, y, radius, color, isSelected ? 4 : heat > 0 ? 3 : node.kind === "project" ? 3 : 1);
// A ring around the hottest nodes, so a hit reads at a glance.
if (heat > 0.55) {
const r = radius + 2;
for (let a = 0; a < 16; a++) {
const t = (a / 16) * Math.PI * 2;
canvas.setDot(x + Math.cos(t) * r, y + Math.sin(t) * r * 0.9, blendHex(ACCENT_CYAN, BG_COLOR, 1 - heat), 2);
}
}
}
// Glyphs and labels are drawn in two separate passes, and the order matters.
// Drawing a glyph and its label per node let a later node's glyph land inside
// an earlier node's label, which is what produced `sear◉hweb` on a busy
// canvas. Every glyph goes down first; labels then treat those cells as
// occupied and route around them.
const neighborIndex = new Map();
if (selected)
controller.neighborsOf(selected.id).slice(0, 9).forEach((node, i) => neighborIndex.set(node.id, i + 1));
const currentHit = searching ? controller.search.results[controller.search.index]?.id : undefined;
const zoomedIn = Math.min(controller.camera.scaleX, controller.camera.scaleY) >= 5;
// Bubbles go down before labels so the labels flow around them; drawn after,
// a bubble clipped whatever label happened to be under it. They are painted
// once more at the very end, because node markers do not check for them.
const bubbles = planBubbles(controller, cols, rows, selected);
for (const b of bubbles)
paintBubble(canvas, b);
// On the sphere only the near half gets glyphs and labels; naming the far
// side too flattened it straight back into a 2D heap.
const ranked = placed.filter((item) => item.t < 0.55 || item.node.id === selected?.id).sort((a, b) => labelPriority(controller, b.node, selected, neighborIndex) - labelPriority(controller, a.node, selected, neighborIndex));
const onCanvas = ({ x, y }) => {
const col = Math.floor(x / 2);
const row = Math.floor(y / 4);
return col < 0 || col >= cols || row < 0 || row >= rows ? null : { col, row };
};
// Pass 1 — glyphs.
for (const item of ranked) {
const at = onCanvas(item);
if (!at)
continue;
const { node } = item;
const isSelected = selected?.id === node.id;
const lit = isLit(node.id);
const glyphColor = isSelected ? ACCENT_AMBER : lit ? node.baseColor : blendHex(node.baseColor, DIM_TARGET, 0.7);
const number = neighborIndex.get(node.id);
if (isSelected)
canvas.putText(at.col, at.row, "◆", sgr(ACCENT_AMBER, "\x1b[1m"));
else if (node.kind === "project")
canvas.putText(at.col, at.row, KIND_GLYPH.project, sgr(glyphColor, "\x1b[1m"));
else if (number !== undefined)
canvas.putText(at.col, at.row, String(number), sgr(ACCENT_CYAN, "\x1b[1m"));
}
// Every project used to force a label, so forty of them piled into one
// unreadable heap. Only as many as the canvas can carry get named, largest
// first; the rest still show their glyph.
const projectLabelBudget = Math.max(4, Math.floor((cols * rows) / 120));
const namedProjects = new Set(placed
.filter(({ node }) => node.kind === "project")
.sort((a, b) => projectWeight(b.node) - projectWeight(a.node))
.slice(0, projectLabelBudget)
.map(({ node }) => node.id));
/** A label needs a clear cell either side, or two labels read as one word. */
const hasRoom = (col, row, w, avoidDots) => {
if (col < 0 || col + w > cols)
return false;
const start = Math.max(0, col - 1);
const end = Math.min(cols, col + w + 1);
return canvas.isFree(start, row, end - start, avoidDots);
};
// Pass 2 — labels.
const labelBudget = searching ? 14 : zoomedIn ? Math.max(8, Math.floor((cols * rows) / 60)) : Math.max(4, Math.floor((cols * rows) / 220));
let labels = 0;
for (const item of ranked) {
const at = onCanvas(item);
if (!at)
continue;
const { node } = item;
const { col, row } = at;
const isSelected = selected?.id === node.id;
const lit = isLit(node.id);
const glyphColor = isSelected ? ACCENT_AMBER : lit ? node.baseColor : blendHex(node.baseColor, DIM_TARGET, 0.7);
const number = neighborIndex.get(node.id);
const forced = isSelected
|| node.id === currentHit
|| heatOf(node.id) > 0
|| (node.kind === "project" ? namedProjects.has(node.id) : node.forceLabel);
const wanted = forced || number !== undefined || (lit && labels < labelBudget && (zoomedIn || searching));
if (!wanted)
continue;
const maxLabel = node.kind === "project" ? 20 : number !== undefined ? 14 : 18;
const text = sliceWidth(node.label, maxLabel);
const w = displayWidth(text);
const labelSgr = isSelected
? `${hexToSgr(BG_COLOR)}\x1b[48;2;255;209;102m\x1b[1m`
: node.kind === "project"
? sgr(glyphColor, "\x1b[1m")
: number !== undefined
? sgr(lit ? blendHex(node.baseColor, "#ffffff", 0.35) : blendHex(node.baseColor, DIM_TARGET, 0.6))
: sgr(lit ? blendHex(node.baseColor, "#ffffff", 0.2) : blendHex(node.baseColor, DIM_TARGET, 0.6));
const slots = [[col + 2, row], [col - w - 1, row], [col + 2, row - 1], [col + 2, row + 1], [col - w - 1, row - 1], [col - w - 1, row + 1]];
// Clean slots first (no text, no dots); forced labels may cover dots as a
// last resort, but never another label.
const placedAt = slots.find(([c, r]) => hasRoom(c, r, w, true)) ?? (forced ? slots.find(([c, r]) => hasRoom(c, r, w, false)) : undefined);
if (!placedAt)
continue;
canvas.putText(placedAt[0], placedAt[1], text, labelSgr);
if (!forced)
labels++;
}
// Agents sit beside the project they are working in. They are runtime state,
// not memory, so they decorate a node rather than becoming one.
if (controller.agents.enabled && controller.agents.agents.length) {
const highlighted = controller.agents.current;
for (const [project, agents] of controller.agents.byProject()) {
const d = controller.projectNode(project);
if (!d || d.t > 0.55)
continue;
const col = Math.floor(d.x / 2);
const row = Math.floor(d.y / 4) - 1;
if (col < 0 || col >= cols || row < 0 || row >= rows)
continue;
const busiest = agents.find((a) => a.status === "working") ?? agents[0];
const isHighlighted = agents.some((a) => a.id === highlighted?.id);
const mark = agents.length > 1 ? `▲${agents.length}` : "▲";
const color = AGENT_STATUS_COLOR[busiest.status] ?? "#7f8db3";
const marker = isHighlighted
? `${hexToSgr(BG_COLOR)}\x1b[48;2;58;227;116m\x1b[1m`
: sgr(color, "\x1b[1m");
canvas.putText(col, row, mark, marker);
}
}
// phren, wherever he currently is. Drawn after everything else so he is
// never lost in the dot field — he is the thing you are meant to follow.
// On the sphere he sits at his node's projected place; the flat walk is 2D.
const mascotAt = controller.orbit
? (controller.mascot.targetNodeId ? controller.projectNode(controller.mascot.targetNodeId) : null)
: (controller.mascot.pos ? controller.project(controller.mascot.pos) : null);
if (mascotAt) {
const d = mascotAt;
const col = Math.floor(d.x / 2);
const row = Math.floor(d.y / 4);
if (col >= 0 && col < cols && row >= 0 && row < rows) {
const glow = controller.mascot.arrivalGlow();
// A cyan halo on arrival, fading out, so a landing reads at a glance.
if (glow > 0) {
for (let a = 0; a < 12; a++) {
const t = (a / 12) * Math.PI * 2;
canvas.setDot(d.x + Math.cos(t) * 5, d.y + Math.sin(t) * 4.5, blendHex(MASCOT_SPARK, BG_COLOR, 1 - glow), 5);
}
}
// He perches *beside* what he is visiting, never on top of it — sitting
// on the node would hide the very marker you are meant to be looking at.
const perch = [[col - 1, row], [col + 1, row], [col, row - 1], [col - 1, row - 1]]
.find(([c, r]) => canvas.isFree(c, r, 1)) ?? [col - 1, row];
if (glow > 0)
canvas.putText(perch[0], perch[1] - 1, "✦", sgr(blendHex(MASCOT_SPARK, BG_COLOR, 1 - glow), "\x1b[1m"));
canvas.putText(perch[0], perch[1], "◕", sgr(MASCOT_COLOR, "\x1b[1m"));
}
}
for (const b of bubbles)
paintBubble(canvas, b);
if (controller.refreshing) {
const badge = " ⟳ refreshing ";
canvas.putText(cols - displayWidth(badge), 0, badge, sgr(ACCENT_CYAN, "\x1b[2m"));
}
return canvas.render();
}
function planBubbles(controller, cols, rows, selected) {
if (controller.reader && selected) {
const at = controller.projectNode(selected.id);
if (!at)
return [];
const knows = selected.kind === "project" && selected.project ? controller.knowsFor(selected.project) : null;
const text = knows ? knows.replace(/\*\*/g, "").replace(/^- /gm, "• ") : (selected.fullLabel || selected.label);
const anchor = toCell(at);
const maxInner = Math.min(64, cols - BUBBLE_CHROME_COLS - 2);
const maxRows = rows - BUBBLE_CHROME_ROWS - 1;
if (maxInner < 12 || maxRows < 2)
return [];
const inner = Math.min(maxInner, Math.max(24, displayWidth(text)));
let lines = wrapText(text, inner, maxRows);
const meta = [selected.project, selected.topicLabel || selected.topicSlug, selected.date, selected.priority].filter(Boolean).join(" · ");
if (meta && lines.length < maxRows)
lines = [...lines, "", style.dim(sliceWidth(meta, inner))];
const width = Math.max(inner, ...lines.map((l) => displayWidth(l)));
const placement = placeBubble(anchor, cols, rows, width, lines.length);
if (!placement)
return [];
const color = selected.kind === "project" ? KIND_COLORS.project : selected.baseColor;
const title = `${colored(color, KIND_GLYPH[selected.kind])} ${style.bold(KIND_LABEL[selected.kind])}`;
return [{ placement, rows: bubbleRows(lines, width, placement, { title, footer: `${style.boldCyan("␣")} ${style.dim("close")}`, frame: (s) => colored(color, s) }), anchor, color }];
}
const live = controller.liveRecall();
if (!live)
return [];
const at = controller.projectNode(live.nodeId);
if (!at)
return [];
const text = live.item.event.snippet || live.item.event.filename || "";
if (!text)
return [];
const anchor = toCell(at);
const inner = Math.min(40, cols - BUBBLE_CHROME_COLS - 2);
if (inner < 12 || rows < 6)
return [];
const fade = live.age / LIVE_BUBBLE_MS;
const ink = blendHex("#e2e8f0", BG_COLOR, fade * 0.6);
const edge = blendHex(ACCENT_CYAN, BG_COLOR, fade * 0.7);
const lines = wrapText(text, inner, 3).map((l) => colored(ink, l));
const width = Math.max(...lines.map((l) => displayWidth(l)), 12);
const placement = placeBubble(anchor, cols, rows, width, lines.length);
if (!placement)
return [];
const source = live.item.event.source || "lookup";
const title = `${colored(edge, "◉")} ${colored(SOURCE_COLOR[source] ?? "#7f8db3", source)} ${style.dim("·")} ${style.cyan(sliceWidth(live.item.event.project, Math.max(4, width - source.length - 6)))}`;
return [{ placement, rows: bubbleRows(lines, width, placement, { title, frame: (s) => colored(edge, s) }), anchor, color: edge }];
}
function toCell(d) {
return { col: Math.floor(d.x / 2), row: Math.floor(d.y / 4) };
}
/** Clear the dots under the bubble, draw its rows, and run a thread to the anchor. */
function paintBubble(canvas, { placement, rows, anchor, color }) {
canvas.clearRect(placement.col, placement.row, placement.width, placement.height);
rows.forEach((line, i) => canvas.putStyled(placement.col, placement.row + i, line));
// A dotted thread from the node to the tail, so the bubble is visibly *about* that node.
const from = { x: anchor.col * 2 + 1, y: anchor.row * 4 + 2 };
const to = placement.side === "right" ? { x: placement.col * 2, y: placement.tailRow * 4 + 2 }
: placement.side === "left" ? { x: (placement.col + placement.width) * 2 - 1, y: placement.tailRow * 4 + 2 }
: placement.side === "below" ? { x: placement.tailCol * 2 + 1, y: placement.row * 4 }
: { x: placement.tailCol * 2 + 1, y: (placement.row + placement.height) * 4 - 1 };
canvas.line(from.x, from.y, to.x, to.y, color, { z: 3, dotted: true });
}
function edgeColor(controller, link, touchesSelected, lit, degree = 0) {
let base;
if (link.kind === "fragment")
base = blendHex(ACCENT_CYAN, BG_COLOR, 0.45);
else if (link.kind === "contradicts")
base = "#ff5470";
else if (link.kind === "supersedes")
base = "#8a93b8";
else {
const leaf = controller.model.nodeById.get(link.target)?.kind === "project" ? link.source : link.target;
const node = controller.model.nodeById.get(leaf);
base = blendHex(node?.baseColor ?? KIND_COLORS.other, BG_COLOR, 0.55);
}
const shown = lit ? base : blendHex(base, BG_COLOR, 0.6);
if (!touchesSelected)
return shown;
const lift = degree > 24 ? 0.22 : degree > 8 ? 0.34 : 0.5;
return blendHex(shown, ACCENT_AMBER, lift);
}
/** How much of the store a project holds; decides which get named when space is short. */
function projectWeight(node) {
return (node.findingCount ?? 0) + (node.taskCount ?? 0) + (node.refCount ?? 0);
}
function labelPriority(controller, node, selected, neighborIndex) {
let score = nodeRank(node, controller.filters, controller.model.scores);
if (selected?.id === node.id)
score += 100000;
if (node.kind === "project")
score += 50000;
if (neighborIndex.has(node.id))
score += 30000 - (neighborIndex.get(node.id) ?? 0);
if (controller.search.matchIds.has(node.id))
score += 20000;
return score;
}
/**
* The one line of graph state worth spending characters on, rendered into the
* shell header rather than a row of its own. A legend of kind counts told you
* nothing the colours on screen did not, but whether you are looking at your
* whole store or a sample of it changes how you read the picture.
*/
export function graphSummary(controller) {
if (controller.status !== "ready")
return "";
const shown = controller.visible.nodes.length;
const total = controller.model.rawNodes.length;
const n = (v) => v.toLocaleString("en-US");
const capped = shown < total ? `${n(shown)} of ${n(total)} nodes` : `${n(shown)} nodes`;
const parts = [capped, `${n(controller.visible.links.length)} edges`];
if (controller.orbit)
parts.push(`${colored(ACCENT_CYAN, "⟲")}${style.dim(" orbit")}`);
if (controller.watchEnabled && controller.watch.running)
parts.push(`${colored(ACCENT_CYAN, "◉")}${style.dim(" live")}`);
return parts.join(style.dim(" · "));
}
function healthDot(node) {
return `${colored(HEALTH_COLOR[node.health], "●")} ${node.health}`;
}
function renderPane(controller, selected, width, height) {
const inner = width - 2;
const watching = controller.watchEnabled && controller.watch.running;
const lines = [];
const push = (line = "") => { if (lines.length < height)
lines.push(` ${line}`); };
const row = (label, value) => push(`${style.dim(label.padEnd(9))}${value}`);
if (selected) {
const detail = controller.detail(selected.id);
push(`${colored(selected.kind === "project" ? KIND_COLORS.project : selected.baseColor, KIND_GLYPH[selected.kind])} ${style.bold(KIND_LABEL[selected.kind])}${selected.kind === "task" && selected.section ? style.dim(` ${selected.section.toLowerCase()}`) : ""}`);
const text = selected.fullLabel || selected.label;
const textBudget = Math.max(4, Math.floor(height * (watching ? 0.3 : 0.4)));
const wrapped = wrapText(text, inner, textBudget);
for (const line of wrapped)
push(line);
if (wrapped.length && wrapped[wrapped.length - 1].endsWith("…") && !text.endsWith("…"))
push(`${style.boldCyan("␣")} ${style.dim("read all of it")}`);
push();
if (selected.kind !== "project" && selected.project)
row("project", style.cyan(selected.project));
if (selected.kind === "project") {
row("findings", String(selected.findingCount ?? 0));
row("tasks", String(selected.taskCount ?? 0));
const knows = selected.project ? controller.knowsFor(selected.project) : null;
if (knows) {
push();
push(style.dim("what phren knows"));
const bullets = knows.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2).replace(/\*\*/g, ""));
const budget = Math.max(3, Math.floor(height * 0.28));
let used = 0;
for (const b of bullets) {
const lines = wrapText(b, inner - 2, 3);
if (used + lines.length > budget)
break;
lines.forEach((l, i) => push(`${i === 0 ? "· " : " "}${l}`));
used += lines.length;
}
if (bullets.length > 1)
push(`${style.boldCyan("␣")} ${style.dim("read all of it")}`);
}
if (selected.store && selected.store !== "primary")
row("store", selected.store);
}
if (selected.topicLabel || selected.topicSlug)
row("topic", colored(selected.baseColor, selected.topicLabel || selected.topicSlug || ""));
if (selected.entityType)
row("type", selected.entityType);
if (selected.priority)
row("priority", selected.priority);
if (selected.date)
row("date", selected.date);
row("health", healthDot(selected));
if (detail?.qualityScore != null)
row("quality", `${Math.round(detail.qualityScore * 100)}%`);
if (detail?.score?.helpful)
row("helpful", `${detail.score.helpful}×`);
if (detail) {
const c = detail.connections;
const bits = [
c.projects ? `${c.projects} proj` : "",
c.findings ? `${c.findings} find` : "",
c.tasks ? `${c.tasks} task` : "",
c.entities ? `${c.entities} frag` : "",
c.references ? `${c.references} ref` : "",
].filter(Boolean);
row("links", `${c.total}${bits.length ? style.dim(` ${bits.join(" ")}`) : ""}`);
}
const docs = (selected.refDocs ?? []).map((ref) => ref.doc);
if (docs.length && selected.kind !== "finding" && selected.kind !== "task") {
row("docs", sliceWidth(docs[0], inner - 9));
for (const doc of docs.slice(1, 3))
push(`${" ".repeat(9)}${sliceWidth(doc, inner - 9)}`);
if (docs.length > 3)
push(`${" ".repeat(9)}${style.dim(`+${docs.length - 3} more`)}`);
}
const neighbors = controller.neighborsOf(selected.id);
if (neighbors.length) {
push();
push(style.dim(`neighbours ${style.dim(`(1-9 jump)`)}`));
const budget = Math.max(0, height - lines.length - 1);
neighbors.slice(0, Math.min(9, budget)).forEach((node, i) => {
push(`${style.boldCyan(String(i + 1))} ${colored(node.baseColor, KIND_GLYPH[node.kind])} ${sliceWidth(node.label, inner - 4)}`);
});
if (neighbors.length > 9)
push(style.dim(` +${neighbors.length - 9} more`));
}
const agentBudget = height - lines.length - 1;
if (agentBudget > 3)
for (const line of agentLines(controller, inner, agentBudget))
push(line);
if (watching) {
const budget = height - lines.length - 1;
if (budget > 3)
for (const line of activityLines(controller, inner, budget))
push(line);
}
}
else {
const total = controller.model.rawNodes.length;
const shown = controller.visible.nodes.length;
push(`${colored(ACCENT_CYAN, "❖")} ${style.bold("knowledge graph")}${controller.refreshing ? style.dim(" ⟳") : ""}${controller.orbit ? ` ${colored(ACCENT_CYAN, "⟲")}${style.dim(" orbit")}` : ""}${watching ? ` ${colored(ACCENT_CYAN, "◉")}${style.dim(" live")}` : ""}`);
push(style.dim(shown === total ? `${shown} nodes · ${controller.visible.links.length} edges` : `${shown} of ${total} nodes · ${controller.visible.links.length} edges`));
push();
row("filter", `${controller.preset.name}${style.dim(" f")}`);
row("focus", `${controller.focusedProject ? style.cyan(controller.focusedProject) : style.dim("all projects")}${style.dim(" [ ]")}`);
row("search", controller.search.query
? `${style.yellow(controller.search.query)} ${style.dim(`${controller.search.results.length} hit${controller.search.results.length === 1 ? "" : "s"}`)}`
: `${style.dim("—")}${style.dim(" /")}`);
push();
const projects = controller.projects;
if (projects.length) {
push(style.dim("projects"));
const budget = Math.max(0, height - lines.length - 9);
for (const project of projects.slice(0, budget)) {
const name = project.project || project.id;
const isFocus = controller.focusedProject === name;
push(`${colored(project.baseColor, KIND_GLYPH.project)} ${isFocus ? style.boldCyan(sliceWidth(name, inner - 12)) : sliceWidth(name, inner - 12)} ${style.dim(`${project.findingCount ?? 0}✦ ${project.taskCount ?? 0}▤`)}`);
}
if (projects.length > budget)
push(style.dim(` +${projects.length - budget} more`));
push();
}
const agentBudget = height - lines.length - 1;
if (agentBudget > 3)
for (const line of agentLines(controller, inner, agentBudget))
push(line);
if (watching) {
const budget = height - lines.length - 1;
if (budget > 3) {
for (const line of activityLines(controller, inner, budget))
push(line);
while (lines.length < height)
lines.push("");
return lines.slice(0, height);
}
}
const k = (s) => style.boldCyan(s);
const d = (s) => style.dim(s);
push(`${k("↑↓←→")} ${d("walk")} ${k("↵")} ${d("select")}`);
push(`${k("1-9")} ${d("jump to neighbour")}`);
push(`${k("/")} ${d("search")} ${k("n")}${d("/")}${k("N")} ${d("next/prev")}`);
push(`${k("f")} ${d("filter")} ${k("[ ]")} ${d("project")}`);
push(`${k("+ -")} ${d("zoom")} ${k("0")} ${d("fit")} ${k("r")} ${d("relayout")}`);
push(`${k("⇧↑↓←→")} ${d("pan")} ${k("o")} ${d("3D viewer")}`);
}
while (lines.length < height)
lines.push("");
return lines.slice(0, height);
}
const AGENT_STATUS_COLOR = {
working: "#3ae374",
idle: "#48b2ff",
done: "#5c6b8a",
error: "#ff5470",
};
const SOURCE_COLOR = {
search: ACCENT_CYAN,
inject: ACCENT_AMBER,
write: "#3ae374",
};
/**
* The live feed: what phren has just landed on, newest first. Each event gets
* a meta line and as much of the memory's text as the pane can carry, because
* the point of watching is reading what was found, not just seeing it blink.
*/
/**
* Who is working, and where. Rendered above the activity feed so the pane reads
* top to bottom as: what is selected, who is on it, what phren just touched.
*/
function agentLines(controller, inner, budget) {
const agents = controller.agents.agents;
const out = [];
if (!controller.agents.enabled)
return out;
out.push("");
out.push(`${colored(agents.length ? "#3ae374" : "#5c6b8a", "▲")} ${style.dim(`agents${agents.length ? "" : " — none running"}`)}`);
const current = controller.agents.current;
for (const agent of agents) {
if (out.length >= budget - 1)
break;
const isCurrent = agent.id === current?.id;
const color = AGENT_STATUS_COLOR[agent.status] ?? "#7f8db3";
const marker = isCurrent ? style.boldCyan("›") : " ";
out.push(`${marker}${colored(color, "●")} ${sliceWidth(agent.label, inner - 3)}`);
if (out.length >= budget - 1)
break;
const where = agent.project ?? style.dim("outside phren");
out.push(` ${style.dim(agent.status)} ${style.dim("·")} ${agent.project ? style.cyan(where) : where}`);
}
if (agents.length)
out.push(style.dim(" tab cycle · ↵ focus"));
return out.slice(0, budget);
}
function activityLines(controller, inner, budget) {
const out = [];
const now = Date.now();
const live = controller.watch.hot;
out.push("");
out.push(`${colored(live ? ACCENT_CYAN : "#5c6b8a", live ? "◉" : "○")} ${style.dim("activity")}`);
if (!controller.watch.activity.length) {
out.push(style.dim(" nothing yet — run a search"));
out.push(style.dim(" in another terminal"));
return out.slice(0, budget);
}
for (const item of controller.watch.activity) {
if (out.length >= budget - 1)
break;
const age = item.historical ? "" : formatAge(now - item.seenAt);
const source = item.event.source || "lookup";
const color = SOURCE_COLOR[source] ?? "#7f8db3";
const heat = item.nodeId ? controller.watch.heatOf(item.nodeId) : 0;
const meta = `${style.dim(age.padEnd(4))}${colored(color, source)} ${style.dim("·")} ${style.cyan(sliceWidth(item.event.project, inner - age.length - source.length - 4))}`;
out.push(heat > 0.5 ? `${meta}` : meta);
const text = item.event.snippet || item.event.filename || "";
if (text && out.length < budget - 1) {
const wrapped = wrapText(text, inner - 2, heat > 0 ? 3 : 2);
for (const line of wrapped) {
if (out.length >= budget - 1)
break;
out.push(` ${heat > 0 ? colored(blendHex("#e2e8f0", ACCENT_CYAN, heat * 0.7), line) : style.dim(line)}`);
}
}
}
return out.slice(0, budget);
}
function renderStrip(controller, selected, width, rows) {
const detail = controller.detail(selected.id);
const lines = [];
const head = [
`${colored(selected.baseColor, KIND_GLYPH[selected.kind])} ${style.bold(KIND_LABEL[selected.kind])}`,
selected.project && selected.kind !== "project" ? style.cyan(selected.project) : "",
selected.topicLabel || selected.topicSlug || "",
healthDot(selected),
detail ? style.dim(`${detail.connections.total} links`) : "",
].filter(Boolean);
lines.push(` ${head.join(style.dim(" · "))}`);
for (const line of wrapText(selected.fullLabel || selected.label, width - 2, 2))
lines.push(` ${line}`);
const neighbors = controller.neighborsOf(selected.id).slice(0, 9);
if (neighbors.length) {
const items = neighbors.map((node, i) => `${style.boldCyan(String(i + 1))} ${colored(node.baseColor, KIND_GLYPH[node.kind])} ${sliceWidth(node.label, 14)}`);
let line = " ";
for (const item of items) {
if (displayWidth(line + item) + 2 > width)
break;
line += `${item} `;
}
lines.push(line.trimEnd());
}
return lines.slice(0, rows);
}