@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
1,044 lines (1,043 loc) • 42.5 kB
JavaScript
/**
* State + interaction for the terminal graph view.
*
* The controller is the terminal-side analogue of the browser's
* `browser/graph/state.ts` + `interactions.ts`: it owns the payload built by
* `buildGraph`, the normalized model from `graph-core`, the force layout, a 2D
* camera, selection/search/focus, and the key map. Rendering lives in
* `graph-view.ts`; this module never writes to the terminal.
*/
import { computePhrenLiveStateToken } from "../../phren-paths.js";
import { buildGraph } from "../../ui/data.js";
import { StoreColorAssigner, bestSearchMatch, buildFullAdjacency, buildVisibleData, nodeDetail, nodeRank, normalizeNode, recomputeSearchMatches, } from "../../graph-core/model.js";
import { errorMessage } from "../../utils.js";
import { logger } from "../../logger.js";
import { style } from "../render.js";
import { ForceSim, bounds } from "./layout.js";
import { GraphWatch } from "./watch.js";
import { GraphAgents } from "./agents.js";
import { GraphMascot } from "./mascot.js";
import { LIVE_BUBBLE_MS } from "./bubble.js";
import { readKnowsBlock } from "../../content/summarize.js";
import { DEFAULT_ORBIT, PITCH_LIMIT, buildOrbitLayout, parseMouse, projectOrbit, yawDelta, yawToFace } from "./orbit.js";
/** Left alone in orbit for this long, the sphere starts turning on its own. */
const IDLE_SPIN_MS = 6000;
/** Nodes drawn at once. Terminals have far fewer pixels than a browser tab. */
export const TUI_NODE_LIMIT = 350;
const ALL_TYPES = { project: true, finding: true, task: true, entity: true, reference: true };
export const FILTER_PRESETS = [
{ name: "all", types: ALL_TYPES, health: "all" },
{ name: "findings", types: { project: true, finding: true }, health: "all" },
{ name: "tasks", types: { project: true, task: true }, health: "all" },
{ name: "fragments", types: { project: true, entity: true, reference: true }, health: "all" },
{ name: "aging", types: ALL_TYPES, health: "aging" },
];
/** How far the x and y scales may diverge when filling the canvas. */
const MAX_STRETCH = 2.2;
const DIRECTIONS = {
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
right: { x: 1, y: 0 },
};
function normalizeArrow(rawKey) {
return /^\x1bO[A-D]$/.test(rawKey) ? `\x1b[${rawKey[2]}` : rawKey;
}
const ARROW_DIRECTION = {
"\x1b[A": "up",
"\x1b[B": "down",
"\x1b[D": "left",
"\x1b[C": "right",
};
/** Shift+arrow in the CSI 1;2 form most terminals emit. */
const SHIFT_ARROW_DIRECTION = {
"\x1b[1;2A": "up",
"\x1b[1;2B": "down",
"\x1b[1;2D": "left",
"\x1b[1;2C": "right",
};
const PAN_LETTER_DIRECTION = { K: "up", J: "down", H: "left", L: "right" };
export class GraphController {
phrenPath;
profile;
status = "idle";
errorText = "";
/** A rebuild is running while the previous picture stays on screen. */
get refreshing() { return this.building !== null && this.payload !== null; }
payload = null;
model = { rawNodes: [], rawLinks: [], nodeById: new Map(), fullAdjacency: new Map(), visibleAdjacency: new Map(), scores: {} };
filters = {
filterTypes: { ...ALL_TYPES },
filterTopics: {},
filterHealth: "all",
filterProject: "all",
filterStore: "all",
searchQuery: "",
nodeLimit: TUI_NODE_LIMIT,
};
visible = { nodes: [], links: [], visibleAdjacency: new Map() };
/** Project nodes in display order for `[`/`]`. */
projects = [];
presetIndex = 0;
selectedId = null;
search = { query: "", matchIds: new Set(), results: [], index: -1 };
camera = { cx: 0, cy: 0, scaleX: 2, scaleY: 2 };
sim = null;
lastPositions = new Map();
viewport = { width: 160, height: 80 };
dataToken = "";
building = null;
repaintHook = null;
timer = null;
cameraTarget = null;
/** Re-frame once the intro settle finishes so the whole map is in view. */
/** Set by pan/zoom; until then a resize re-fits the whole map. */
userMoved = false;
builder;
tokenOf;
frameMs;
/** Live tail of what phren is landing on, in this or any other terminal. */
watch;
/** Coding agents running on this machine, joined onto their projects. */
agents;
/** phren himself, who walks to whatever the store just touched. */
mascot = new GraphMascot();
/** Space opens the selected node's full text in a bubble on the canvas. */
reader = false;
knowsCache = new Map();
/**
* The "What phren knows" paragraph for a project, written by
* `phren maintain summarize`; null until it has run. Read once per project
* per data load, so the pane can show it on every frame.
*/
knowsFor(project) {
if (!this.knowsCache.has(project)) {
let text = null;
try {
text = readKnowsBlock(this.phrenPath, project)?.text.replace(/^## What phren knows\s*/m, "").trim() ?? null;
}
catch {
text = null;
}
this.knowsCache.set(project, text);
}
return this.knowsCache.get(project) ?? null;
}
/**
* v: the same graph as a sphere. Selection, search, watch mode and the
* neighbour numbers all keep working; the camera orbits instead of panning.
*/
orbit = false;
orbitCamera = { ...DEFAULT_ORBIT };
orbitPositions = new Map();
orbitRadius = 1;
orbitYawTarget = null;
lastOrbitTouch = 0;
drag = null;
/** Where the canvas sits in the terminal, so mouse coordinates can be mapped onto it. */
canvasOrigin = { col: 0, row: 2 };
watchEnabled;
/**
* Wall-clock ms of the last navigation keypress. While the user is driving,
* incoming events still pulse and feed but do not steal the camera.
*/
lastUserInputAt = 0;
constructor(phrenPath, profile, opts = {}) {
this.phrenPath = phrenPath;
this.profile = profile;
this.builder = opts.builder ?? ((p, prof) => buildGraph(p, prof, undefined, null, { includeFragmentEdges: true, includeLifecycleEdges: true }));
this.tokenOf = opts.tokenOf ?? computePhrenLiveStateToken;
this.frameMs = opts.frameMs ?? 50;
this.watch = opts.watch ?? new GraphWatch(phrenPath);
this.watchEnabled = opts.watchEnabled !== false;
this.agents = opts.agents ?? new GraphAgents(phrenPath, profile);
}
// ── Watch mode ──────────────────────────────────────────────────────────
/** Idempotent; called every render so the tail starts with the view. */
startWatch() {
if (!this.watchEnabled || this.watch.running)
return;
this.watch.start((items) => this.onWatchEvents(items));
}
stopWatch() {
this.watch.stop();
}
// ── Agents ──────────────────────────────────────────────────────────────
/** Idempotent; the poller only runs while the Graph view is open. */
startAgents() {
if (!this.agents.enabled || this.agents.running)
return;
this.agents.start(() => this.repaintHook?.());
}
/**
* The recall that just landed, while it is still fresh enough to show at its
* node. Watch mode's feed lists everything; this is the one worth pointing at.
*/
liveRecall(now = Date.now()) {
if (!this.watchEnabled || !this.watch.running)
return null;
for (const item of this.watch.activity) {
if (item.historical || !item.nodeId)
continue;
const age = now - item.seenAt;
if (age > LIVE_BUBBLE_MS)
return null;
if (!this.positions.has(item.nodeId))
return null;
return { nodeId: item.nodeId, item, age };
}
return null;
}
/**
* Offer the overlay once per session when agents are actually running but it
* is switched off. Shipping a feature off by default and never mentioning it
* is the same as not shipping it.
*/
agentHint() {
if (this.offeredAgents || this.agents.enabled || this.status !== "ready")
return null;
this.offeredAgents = true;
return this.agents.hasSomethingToShow() ? "agents are running on this machine" : null;
}
offeredAgents = false;
toggleAgents() {
if (this.agents.enabled) {
this.agents.toggle();
return false;
}
this.agents.enabled = true;
this.agents.start(() => this.repaintHook?.());
return true;
}
toggleWatch() {
this.watchEnabled = !this.watchEnabled;
if (this.watchEnabled)
this.startWatch();
else
this.stopWatch();
return this.watchEnabled;
}
/** True while the user is actively driving, so the camera is left alone. */
get userDriving() {
return Date.now() - this.lastUserInputAt < 4000;
}
/**
* New events landed. Light every node they touch, then follow the newest
* one that is actually on screen — unless the user is mid-navigation.
*/
onWatchEvents(items) {
let followed = false;
for (let i = items.length - 1; i >= 0 && !followed; i--) {
const nodeId = items[i].nodeId;
if (!nodeId || !this.model.nodeById.has(nodeId))
continue;
this.mascot.walkTo(nodeId, this.positions);
if (!this.userDriving) {
this.selectedId = nodeId;
this.flyTo(nodeId);
}
followed = true;
}
// Heat decays over several seconds, so keep painting even without a fly.
this.startAnimation();
this.repaintHook?.();
}
// ── Data ────────────────────────────────────────────────────────────────
/**
* Let the shell trigger a repaint on its own (settle animation, fly-to,
* a build finishing). Without it the controller blocks the first render on
* the build and snaps every camera move.
*/
setRepaintHook(hook) {
this.repaintHook = hook;
}
get positions() {
return this.sim ? this.sim.positions : this.lastPositions;
}
lastToken = "";
lastTokenAt = 0;
/** The store token, re-read at most every 2s: animation repaints must not stat the whole store. */
currentToken() {
const now = Date.now();
if (now - this.lastTokenAt < 2000 && this.lastToken)
return this.lastToken;
try {
this.lastToken = this.tokenOf(this.phrenPath);
}
catch {
this.lastToken = `err:${now}`;
}
this.lastTokenAt = now;
return this.lastToken;
}
/**
* Make sure a payload matching the store is loaded, or on its way. Blocks
* only on the very first build when there is no repaint hook to come back
* through; otherwise the old picture stays up with a refreshing badge.
*/
async ensureData() {
this.startWatch();
this.startAgents();
const token = this.currentToken();
if (this.payload && token === this.dataToken)
return;
if (!this.building) {
if (!this.payload)
this.status = "loading";
this.building = this.build(token).finally(() => { this.building = null; });
}
if (!this.payload && !this.repaintHook)
await this.building;
}
async build(token) {
try {
const payload = await this.builder(this.phrenPath, this.profile);
this.dataToken = token;
this.adopt(payload);
}
catch (err) {
this.errorText = errorMessage(err);
this.status = "error";
logger.debug("shell", `graph build failed: ${this.errorText}`);
}
finally {
this.repaintHook?.();
}
}
/** Load a payload directly (tests, or a host that already has one). */
adopt(payload) {
this.payload = payload;
const scores = payload.scores ?? {};
const storeColors = new StoreColorAssigner();
const rawNodes = (payload.nodes ?? []).map((node) => normalizeNode(node, scores, (s) => storeColors.color(s)));
const rawLinks = payload.links ?? [];
this.model = {
rawNodes,
rawLinks,
nodeById: new Map(rawNodes.map((node) => [node.id, node])),
fullAdjacency: buildFullAdjacency(rawNodes, rawLinks),
visibleAdjacency: new Map(),
scores,
};
this.projects = rawNodes.filter((node) => node.kind === "project").sort((a, b) => a.label.localeCompare(b.label));
if (this.filters.filterProject !== "all" && !this.projects.some((p) => (p.project || p.id) === this.filters.filterProject)) {
this.filters.filterProject = "all";
}
if (this.selectedId && !this.model.nodeById.has(this.selectedId))
this.selectedId = null;
this.status = rawNodes.length ? "ready" : "empty";
this.applyFilters(true);
}
// ── Filters / layout ────────────────────────────────────────────────────
get preset() {
return FILTER_PRESETS[this.presetIndex] ?? FILTER_PRESETS[0];
}
get focusedProject() {
return this.filters.filterProject === "all" ? null : this.filters.filterProject;
}
applyFilters(warm) {
this.visible = buildVisibleData(this.model, this.filters, this.selectedId);
this.model.visibleAdjacency = this.visible.visibleAdjacency;
const visibleIds = new Set(this.visible.nodes.map((node) => node.id));
if (this.selectedId && !visibleIds.has(this.selectedId))
this.selectedId = null;
this.rebuildLayout(warm);
this.refreshSearch();
}
rebuildLayout(warm) {
if (this.sim)
this.lastPositions = new Map([...this.sim.positions].map(([id, p]) => [id, { ...p }]));
const nodes = this.visible.nodes.map((node) => ({ id: node.id, kind: node.kind, project: node.project, size: node.size }));
const links = this.visible.links;
// The layout is shaped to the canvas so it fills a wide terminal.
this.sim = new ForceSim(nodes, links, undefined, this.viewport.width / Math.max(1, this.viewport.height));
if (warm && this.lastPositions.size)
this.sim.warmStart(this.lastPositions);
this.mascot.reset();
this.knowsCache.clear();
// Settle before anyone sees it. Animating the relaxation instead — ticking
// a few steps, framing the half-settled positions, then letting the rest
// play out under a fixed camera — read as the whole cluster bouncing for a
// second every time a project came into focus. A layout change is a cut,
// not a motion; the camera flights are the only thing meant to move. It
// is cheap enough not to notice: under 10ms for a couple of hundred nodes,
// and the focused subset that [ ] shows is far smaller than that.
this.sim.settle();
this.rebuildOrbit();
if (!warm || !this.lastPositions.size)
this.fitAll();
// One frame through the loop repaints, then it stops itself unless the
// mascot, a flight or watch mode has something to show.
if (this.repaintHook)
this.startAnimation();
}
/**
* The view reports its canvas size (in dots) before projecting. The first
* fit happens before any frame is drawn, so a size change re-fits unless
* the user has taken the camera over.
*/
setViewport(width, height) {
if (width === this.viewport.width && height === this.viewport.height)
return;
this.viewport = { width: Math.max(2, width), height: Math.max(4, height) };
if (!this.userMoved)
this.fitAll();
else if (this.selectedId)
this.keepInView(this.selectedId);
}
get viewportSize() {
return this.viewport;
}
/** The sphere is derived from the settled flat layout, never simulated. */
rebuildOrbit() {
const nodes = this.visible.nodes.map((node) => ({ id: node.id, kind: node.kind, project: node.project, size: node.size }));
const built = buildOrbitLayout(nodes, this.visible.links, this.positions);
this.orbitPositions = built.positions;
this.orbitRadius = built.radius;
}
/** A node's place on screen, with depth: 0 nearest, 1 farthest, always 0 on the flat map. */
projectNode(id) {
if (this.orbit) {
const v = this.orbitPositions.get(id);
return v ? projectOrbit(v, this.orbitCamera, this.viewport, this.orbitRadius) : null;
}
const p = this.positions.get(id);
return p ? { ...this.project(p), t: 0 } : null;
}
toggleOrbit() {
this.orbit = !this.orbit;
this.drag = null;
this.orbitYawTarget = null;
this.lastOrbitTouch = Date.now();
if (this.orbit) {
this.orbitCamera = { ...DEFAULT_ORBIT };
if (this.selectedId)
this.flyTo(this.selectedId);
if (this.repaintHook)
this.startAnimation();
}
return this.orbit;
}
/** In orbit, the pan keys turn the sphere instead. */
turn(direction) {
const d = DIRECTIONS[direction];
this.orbitCamera.yaw += d.x * 0.12;
this.orbitCamera.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, this.orbitCamera.pitch + d.y * 0.08));
this.orbitYawTarget = null;
this.lastOrbitTouch = Date.now();
}
/** The visible node nearest a dot on the canvas, within a radius. */
nearestToDot(x, y, within) {
let best = null;
let bestDistance = within;
for (const node of this.visible.nodes) {
const d = this.projectNode(node.id);
if (!d)
continue;
// A project marker has findings packed right up against it; a click on
// the marker means the project, so it wins a few dots of slack.
const distance = Math.hypot(d.x - x, d.y - y) - (node.kind === "project" ? 4 : 0);
if (distance < bestDistance) {
bestDistance = distance;
best = node;
}
}
return best;
}
/**
* Mouse: wheel zooms in both modes; a left drag turns the sphere or pans
* the map; a click with no movement selects the node under it.
*/
handleMouse(m, host) {
if (m.type === "wheel") {
this.zoom(m.delta < 0 ? 1.15 : 1 / 1.15);
return true;
}
if (m.type === "press") {
if (m.button === 0)
this.drag = { col: m.col, row: m.row, moved: false };
return true;
}
if (m.type === "drag") {
if (!this.drag)
return true;
const dx = m.col - this.drag.col;
const dy = m.row - this.drag.row;
if (dx === 0 && dy === 0)
return true;
this.drag = { col: m.col, row: m.row, moved: true };
if (this.orbit) {
this.orbitCamera.yaw += dx * 0.035;
this.orbitCamera.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, this.orbitCamera.pitch + dy * 0.06));
this.orbitYawTarget = null;
this.lastOrbitTouch = Date.now();
}
else {
this.userMoved = true;
this.cameraTarget = null;
this.camera.cx -= (dx * 2) / this.camera.scaleX;
this.camera.cy -= (dy * 4) / this.camera.scaleY;
}
return true;
}
const wasClick = this.drag !== null && !this.drag.moved;
this.drag = null;
if (wasClick) {
const x = (m.col - this.canvasOrigin.col) * 2 + 1;
const y = (m.row - this.canvasOrigin.row) * 4 + 2;
const node = this.nearestToDot(x, y, 12);
if (node) {
this.select(node.id);
host.setMessage(this.describe(node));
}
}
return true;
}
/** World → dot coordinates for the current camera. */
project(p) {
return {
x: (p.x - this.camera.cx) * this.camera.scaleX + this.viewport.width / 2,
y: (p.y - this.camera.cy) * this.camera.scaleY + this.viewport.height / 2,
};
}
fitAll() {
if (this.orbit) {
this.orbitCamera.zoom = 1;
this.orbitCamera.pitch = DEFAULT_ORBIT.pitch;
this.lastOrbitTouch = Date.now();
}
const box = bounds(this.positions.values());
this.cameraTarget = null;
this.userMoved = false;
if (!box) {
this.camera = { cx: 0, cy: 0, scaleX: 2, scaleY: 2 };
return;
}
const w = Math.max(1, box.maxX - box.minX);
const h = Math.max(1, box.maxY - box.minY);
const pad = 0.92;
let sx = (this.viewport.width * pad) / w;
let sy = (this.viewport.height * pad) / h;
// Fill both axes rather than letting the shorter one strand the rest of
// the screen, but pull the looser axis back in once it would distort.
if (sx > sy * MAX_STRETCH)
sx = sy * MAX_STRETCH;
else if (sy > sx * MAX_STRETCH)
sy = sx * MAX_STRETCH;
const clamp01 = (v) => Math.max(0.2, Math.min(v, 12));
this.camera = {
cx: (box.minX + box.maxX) / 2,
cy: (box.minY + box.maxY) / 2,
scaleX: clamp01(sx),
scaleY: clamp01(sy),
};
}
zoom(factor) {
if (this.orbit) {
this.orbitCamera.zoom = Math.max(0.4, Math.min(4, this.orbitCamera.zoom * factor));
this.lastOrbitTouch = Date.now();
return;
}
this.userMoved = true;
// Both axes move together, so a zoom never changes the shape on screen.
const clampZoom = (v) => Math.max(0.2, Math.min(24, v));
this.camera.scaleX = clampZoom(this.camera.scaleX * factor);
this.camera.scaleY = clampZoom(this.camera.scaleY * factor);
}
pan(direction) {
if (this.orbit) {
this.turn(direction);
return;
}
const d = DIRECTIONS[direction];
const stepX = (this.viewport.width * 0.12) / this.camera.scaleX;
const stepY = (this.viewport.height * 0.12) / this.camera.scaleY;
this.userMoved = true;
this.cameraTarget = null;
this.camera.cx += d.x * stepX;
this.camera.cy += d.y * stepY;
}
/** Centre the camera on a node, eased when animation is available. */
flyTo(nodeId) {
if (this.orbit) {
// Turn the sphere so the node faces you, eased like a flight.
const v = this.orbitPositions.get(nodeId);
if (!v)
return;
this.orbitYawTarget = yawToFace(v);
if (this.repaintHook)
this.startAnimation();
else {
this.orbitCamera.yaw = this.orbitYawTarget;
this.orbitYawTarget = null;
}
return;
}
const p = this.positions.get(nodeId);
if (!p)
return;
if (this.repaintHook) {
this.cameraTarget = { x: p.x, y: p.y };
this.startAnimation();
}
else {
this.camera.cx = p.x;
this.camera.cy = p.y;
}
}
/** Fly only when the node sits outside the middle band of the viewport. */
keepInView(nodeId) {
if (this.orbit) {
this.flyTo(nodeId);
return;
}
const p = this.positions.get(nodeId);
if (!p)
return;
const d = this.project(p);
const { width, height } = this.viewport;
if (d.x < width * 0.18 || d.x > width * 0.82 || d.y < height * 0.18 || d.y > height * 0.82)
this.flyTo(nodeId);
}
relayout() {
this.lastPositions = new Map();
this.cameraTarget = null;
this.rebuildLayout(false);
this.fitAll();
}
// ── Animation ───────────────────────────────────────────────────────────
startAnimation() {
if (this.timer || !this.repaintHook)
return;
this.timer = setInterval(() => this.animationFrame(), this.frameMs);
this.timer.unref?.();
}
stopAnimation() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
get animating() {
return this.timer !== null;
}
animationFrame() {
let busy = this.watch.hot;
if (this.orbit) {
if (this.orbitYawTarget !== null) {
const d = yawDelta(this.orbitCamera.yaw, this.orbitYawTarget);
if (Math.abs(d) < 0.01) {
this.orbitCamera.yaw = this.orbitYawTarget;
this.orbitYawTarget = null;
}
else
this.orbitCamera.yaw += d * 0.25;
}
else if (!this.drag && Date.now() - this.lastOrbitTouch > IDLE_SPIN_MS) {
// Left alone, it turns slowly, so the depth is always there to see.
this.orbitCamera.yaw += 0.005;
}
// The loop stays alive in orbit so the idle spin can start on its own.
busy = true;
}
if (this.liveRecall())
busy = true;
if (this.mascot.step())
busy = true;
if (this.mascot.arrivalGlow() > 0)
busy = true;
if (this.mascot.maybeWander(this.visible.nodes.map((node) => node.id), this.positions))
busy = true;
// Only a graph too large to settle within the tick budget gets here; it
// finishes relaxing quietly under whatever camera it was given.
if (this.sim && !this.sim.settled) {
this.sim.tick(3);
busy = true;
}
if (this.cameraTarget) {
const t = this.cameraTarget;
this.camera.cx += (t.x - this.camera.cx) * 0.35;
this.camera.cy += (t.y - this.camera.cy) * 0.35;
if (Math.abs(t.x - this.camera.cx) < 0.05 && Math.abs(t.y - this.camera.cy) < 0.05) {
this.camera.cx = t.x;
this.camera.cy = t.y;
this.cameraTarget = null;
}
else {
busy = true;
}
}
if (!busy)
this.stopAnimation();
this.repaintHook?.();
}
/** Called by the shell when the view changes away or the shell closes. */
dispose() {
this.stopAnimation();
this.stopWatch();
this.agents.stop();
}
// ── Selection / search / focus ──────────────────────────────────────────
detail(nodeId) {
return nodeDetail(this.model, nodeId);
}
/** Visible neighbours of a node, best first — what the pane numbers 1-9. */
neighborsOf(nodeId) {
const ids = this.model.visibleAdjacency.get(nodeId);
if (!ids)
return [];
const nodes = [];
ids.forEach((id) => { const node = this.model.nodeById.get(id); if (node)
nodes.push(node); });
return nodes.sort((a, b) => nodeRank(b, this.filters, this.model.scores) - nodeRank(a, this.filters, this.model.scores));
}
select(nodeId, opts = {}) {
if (nodeId !== this.selectedId)
this.reader = false;
this.selectedId = nodeId;
if (nodeId) {
this.mascot.walkTo(nodeId, this.positions);
if (opts.fly)
this.flyTo(nodeId);
else
this.keepInView(nodeId);
}
}
nearestToCenter() {
let best = null;
let bestDist = Infinity;
const center = { x: this.viewport.width / 2, y: this.viewport.height / 2 };
for (const node of this.visible.nodes) {
const p = this.positions.get(node.id);
if (!p)
continue;
const d = this.project(p);
const dist = (d.x - center.x) ** 2 + (d.y - center.y) ** 2;
if (dist < bestDist) {
bestDist = dist;
best = node;
}
}
return best;
}
/**
* Arrow-key traversal: prefer a connected neighbour in that direction; if
* there is none, take the nearest visible node in that half-plane so the
* walk never dead-ends on a leaf.
*/
walk(direction) {
if (!this.selectedId) {
const start = this.nearestToCenter();
if (start)
this.select(start.id);
return start;
}
const from = this.positions.get(this.selectedId);
if (!from)
return null;
const dir = DIRECTIONS[direction];
const pick = (candidates, minCos) => {
let best = null;
for (const id of candidates) {
if (id === this.selectedId)
continue;
const p = this.positions.get(id);
if (!p)
continue;
const dx = p.x - from.x;
const dy = p.y - from.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len === 0)
continue;
const cos = (dx * dir.x + dy * dir.y) / len;
if (cos <= minCos)
continue;
const score = len / Math.max(cos, 0.15);
if (!best || score < best.score)
best = { id, score };
}
return best;
};
const neighbors = this.model.visibleAdjacency.get(this.selectedId) ?? new Set();
const target = pick(neighbors, 0.1) ?? pick(this.visible.nodes.map((node) => node.id), 0.3);
if (!target)
return null;
this.select(target.id);
return this.model.nodeById.get(target.id) ?? null;
}
refreshSearch() {
const matches = recomputeSearchMatches(this.visible.nodes, this.search.query, this.filters, this.model.scores);
this.search.matchIds = matches.matchIds;
this.search.results = matches.results;
if (this.search.index >= matches.results.length)
this.search.index = matches.results.length ? 0 : -1;
}
/** Set the query, light up matches, and fly to the best one. */
applySearch(query) {
this.search.query = query.trim();
this.filters.searchQuery = this.search.query;
this.refreshSearch();
if (!this.search.query) {
this.search.index = -1;
return null;
}
const best = bestSearchMatch(this.search.results);
this.search.index = best ? 0 : -1;
if (best)
this.select(best.id, { fly: true });
return best;
}
clearSearch() {
this.applySearch("");
}
stepSearch(delta) {
const results = this.search.results;
if (!results.length)
return null;
this.search.index = ((this.search.index + delta) % results.length + results.length) % results.length;
const node = results[this.search.index];
this.select(node.id, { fly: true });
return node;
}
cyclePreset(delta = 1) {
this.presetIndex = ((this.presetIndex + delta) % FILTER_PRESETS.length + FILTER_PRESETS.length) % FILTER_PRESETS.length;
const preset = this.preset;
this.filters.filterTypes = { ...preset.types };
this.filters.filterHealth = preset.health;
this.applyFilters(true);
return preset;
}
/** Focus one project (name) or all (null). */
focusProject(name) {
this.filters.filterProject = name ?? "all";
this.applyFilters(true);
// Either way the visible set just changed shape: frame it, then keep the
// project selected so its neighbours are numbered.
this.cameraTarget = null;
this.fitAll();
if (name) {
const node = this.projects.find((p) => (p.project || p.id) === name);
if (node)
this.selectedId = node.id;
}
}
cycleProject(delta) {
if (!this.projects.length)
return null;
const names = this.projects.map((p) => p.project || p.id);
const current = this.focusedProject ? names.indexOf(this.focusedProject) : -1;
// Slot -1 is "all projects"; the cycle runs all → first … last → all.
const slots = names.length + 1;
const next = (((current + 1 + delta) % slots) + slots) % slots - 1;
const name = next < 0 ? null : names[next];
this.focusProject(name);
return name;
}
jumpToNeighbor(n) {
if (!this.selectedId)
return null;
const node = this.neighborsOf(this.selectedId)[n - 1];
if (!node)
return null;
this.select(node.id);
return node;
}
// ── Keys ────────────────────────────────────────────────────────────────
/**
* Returns true when the key was consumed, undefined to let the shell's
* generic handler have it (q, :, ?, view shortcuts, final Esc).
*/
handleKey(rawKey, host) {
const mouse = parseMouse(rawKey);
if (mouse) {
this.lastUserInputAt = Date.now();
return this.status === "ready" ? this.handleMouse(mouse, host) : true;
}
const key = normalizeArrow(rawKey);
if (key !== "w" && key !== "W")
this.lastUserInputAt = Date.now();
if (this.status !== "ready") {
if (key === "r") {
this.dataToken = "";
host.setMessage(" Rebuilding graph…");
return true;
}
return undefined;
}
const arrow = ARROW_DIRECTION[key];
if (arrow) {
const node = this.walk(arrow);
host.setMessage(node ? this.describe(node) : ` ${style.dim("nothing further that way")}`);
return true;
}
const shifted = SHIFT_ARROW_DIRECTION[key] ?? PAN_LETTER_DIRECTION[key];
if (shifted) {
this.pan(shifted);
return true;
}
if (key === "+" || key === "=") {
this.zoom(1.25);
return true;
}
if (key === "-" || key === "_") {
this.zoom(1 / 1.25);
return true;
}
if (key === "0") {
this.fitAll();
host.setMessage(` ${style.dim("fit to screen")}`);
return true;
}
if (key === "r") {
this.relayout();
host.setMessage(` ${style.dim("re-laid out")}`);
return true;
}
if (key === "\r" || key === "\n") {
// A highlighted agent takes the Enter: bring it to the front.
const highlighted = this.agents.current;
if (highlighted) {
const focused = this.agents.focusCurrent();
host.setMessage(focused
? ` ${style.boldCyan("→")} ${focused.label}`
: ` ${style.dim(highlighted.focus?.length ? "could not focus that agent" : "this agent's host cannot be focused")}`);
return true;
}
if (!this.selectedId) {
const node = this.nearestToCenter();
if (node) {
this.select(node.id);
host.setMessage(this.describe(node));
}
return true;
}
const node = this.model.nodeById.get(this.selectedId);
if (node?.kind === "project") {
const name = node.project || node.id;
const next = this.focusedProject === name ? null : name;
this.focusProject(next);
host.setMessage(next ? ` ${style.boldCyan("❖")} ${style.boldCyan(next)} ${style.dim("focused — ↵ again to release")}` : ` ${style.dim("all projects")}`);
}
else if (node) {
this.flyTo(node.id);
host.setMessage(this.describe(node));
}
return true;
}
if (/^[1-9]$/.test(key)) {
const node = this.jumpToNeighbor(Number(key));
if (node)
host.setMessage(this.describe(node));
else
host.setMessage(` ${style.dim(this.selectedId ? "no such neighbour" : "select a node first (↵)")}`);
return true;
}
if (key === "/") {
host.startInput("graph-search", this.search.query);
return true;
}
if (key === "n" || key === "N") {
const node = this.stepSearch(key === "n" ? 1 : -1);
host.setMessage(node
? ` ${style.yellow(`${this.search.index + 1}/${this.search.results.length}`)} ${this.describe(node).trimStart()}`
: ` ${style.dim("no search — press / first")}`);
return true;
}
if (key === "f") {
const preset = this.cyclePreset(1);
host.setMessage(` ${style.boldCyan("filter")} ${preset.name} ${style.dim(`${this.visible.nodes.length} nodes`)}`);
return true;
}
if (key === "F") {
const preset = this.cyclePreset(-1);
host.setMessage(` ${style.boldCyan("filter")} ${preset.name} ${style.dim(`${this.visible.nodes.length} nodes`)}`);
return true;
}
if (key === "]" || key === "[") {
const name = this.cycleProject(key === "]" ? 1 : -1);
host.setMessage(name ? ` ${style.boldCyan("❖")} ${style.boldCyan(name)}` : ` ${style.dim("all projects")}`);
return true;
}
if (key === "a" || key === "A") {
const on = this.toggleAgents();
host.setMessage(on
? ` ${style.boldCyan("◉ agents")} ${style.dim(`— ${this.agents.agents.length} running · tab to cycle, ↵ to focus`)}`
: ` ${style.dim("agents off")}`);
return true;
}
if (key === "\t" || key === "\x1b[Z") {
if (!this.agents.enabled || !this.agents.agents.length)
return undefined;
const agent = this.agents.cycle(key === "\t" ? 1 : -1);
if (agent) {
if (agent.project && this.model.nodeById.has(agent.project))
this.select(agent.project, { fly: true });
host.setMessage(` ${style.boldCyan(agent.label)} ${style.dim(`${agent.status}${agent.project ? ` · ${agent.project}` : " · outside phren"}`)}`);
}
return true;
}
if (key === "w" || key === "W") {
const on = this.toggleWatch();
host.setMessage(on
? ` ${style.boldCyan("◉ watching")} ${style.dim("— lighting up what phren touches, anywhere on this machine")}`
: ` ${style.dim("watch off")}`);
return true;
}
if (key === "v") {
const on = this.toggleOrbit();
host.setMessage(on
? ` ${style.boldCyan("⟲")} ${style.dim("orbit — drag to turn, wheel to zoom,")} ${style.boldCyan("v")} ${style.dim("back to the map")}`
: ` ${style.dim("flat map")}`);
return true;
}
if (key === "o") {
host.setMessage(` ${style.dim("open the 3D viewer in a browser:")} ${style.boldCyan("phren web-ui")}`);
return true;
}
if (key === " ") {
if (!this.selectedId) {
host.setMessage(` ${style.dim("select a node first — ↵ or 1-9")}`);
return true;
}
this.reader = !this.reader;
host.setMessage(this.reader ? ` ${style.dim("reading —")} ${style.boldCyan("␣")} ${style.dim("or")} ${style.boldCyan("esc")} ${style.dim("to close")}` : "");
return true;
}
if (key === "\x1b") {
if (this.reader) {
this.reader = false;
host.setMessage("");
return true;
}
if (this.agents.current) {
this.agents.clearHighlight();
host.setMessage(` ${style.dim("agent released")}`);
return true;
}
if (this.search.query) {
this.clearSearch();
host.setMessage(` ${style.dim("search cleared")}`);
return true;
}
if (this.selectedId) {
this.select(null);
host.setMessage(` ${style.dim("selection cleared")}`);
return true;
}
if (this.focusedProject) {
this.focusProject(null);
host.setMessage(` ${style.dim("all projects")}`);
return true;
}
return undefined;
}
return undefined;
}
/** One-line description for the message bar. */
describe(node) {
const label = node.fullLabel || node.label;
const short = label.length > 70 ? `${label.slice(0, 68)}…` : label;
const where = node.project && node.kind !== "project" ? ` ${style.dim("·")} ${style.cyan(node.project)}` : "";
return ` ${style.dim(node.kind)} ${short}${where}`;
}
}