UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

730 lines (729 loc) • 36.7 kB
/** * 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); }