UNPKG

@phren/cli

Version:

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

434 lines (433 loc) • 21.4 kB
// ── ANSI utilities ────────────────────────────────────────────────────────── import { stripTerminal } from "../terminal-text.js"; const ESC = "\x1b["; export const RESET = `${ESC}0m`; export const BOLD = `${ESC}1m`; export const DIM = `${ESC}2m`; export const GREEN = `${ESC}32m`; export const YELLOW = `${ESC}33m`; export const RED = `${ESC}31m`; export const CYAN = `${ESC}36m`; export const style = { bold: (s) => `${ESC}1m${s}${RESET}`, dim: (s) => `${ESC}2m${s}${RESET}`, italic: (s) => `${ESC}3m${s}${RESET}`, cyan: (s) => `${ESC}36m${s}${RESET}`, green: (s) => `${ESC}32m${s}${RESET}`, yellow: (s) => `${ESC}33m${s}${RESET}`, red: (s) => `${ESC}31m${s}${RESET}`, magenta: (s) => `${ESC}35m${s}${RESET}`, blue: (s) => `${ESC}34m${s}${RESET}`, white: (s) => `${ESC}37m${s}${RESET}`, gray: (s) => `${ESC}90m${s}${RESET}`, boldCyan: (s) => `${ESC}1;36m${s}${RESET}`, boldGreen: (s) => `${ESC}1;32m${s}${RESET}`, boldYellow: (s) => `${ESC}1;33m${s}${RESET}`, boldRed: (s) => `${ESC}1;31m${s}${RESET}`, boldMagenta: (s) => `${ESC}1;35m${s}${RESET}`, boldBlue: (s) => `${ESC}1;34m${s}${RESET}`, dimItalic: (s) => `${ESC}2;3m${s}${RESET}`, invert: (s) => `${ESC}7m${s}${RESET}`, }; export function badge(label, colorFn) { return colorFn(`[${label}]`); } export function separator(width = 50) { return style.dim("━".repeat(Math.max(1, width))); } /** The shell's name for {@link stripTerminal}. */ export const stripAnsi = stripTerminal; // ── Display width ──────────────────────────────────────────────────────────── // String length counts UTF-16 code units, which is not what a terminal draws: // combining marks occupy no cell, and East Asian Wide / emoji-presentation // characters occupy two. Measuring with .length under-counts those, so a line // built to "fit" overflows, the terminal autowraps it, and every row below // shifts — which corrupts the whole in-place redraw. Ambiguous-width characters // are counted as one cell, matching the default in Windows Terminal and most // Unix terminals. const ZERO_WIDTH = /^\p{M}$/u; // East Asian Wide/Fullwidth blocks plus the BMP characters with // Emoji_Presentation=Yes (which terminals draw double-wide). const WIDE_RANGES = [ [0x1100, 0x115f], [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb], [0x1f900, 0x1f9ff], [0x1fa70, 0x1faff], [0x20000, 0x3fffd], ]; function isWideCodePoint(cp) { let lo = 0, hi = WIDE_RANGES.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; const range = WIDE_RANGES[mid]; if (cp < range[0]) hi = mid - 1; else if (cp > range[1]) lo = mid + 1; else return true; } return false; } /** Terminal cells occupied by `s`, ignoring any ANSI escape codes it contains. */ export function displayWidth(s) { const plain = stripAnsi(s); let width = 0; for (let i = 0; i < plain.length;) { const cp = plain.codePointAt(i); const size = cp > 0xffff ? 2 : 1; i += size; // Zero-width joiner, bidi/format controls and byte-order mark draw nothing. if (cp === 0x200d || (cp >= 0x200b && cp <= 0x200f) || cp === 0xfeff) continue; // Variation selectors: VS16 promotes the previous character to emoji width. if (cp >= 0xfe00 && cp <= 0xfe0f) { if (cp === 0xfe0f) width += 1; continue; } if (cp >= 0xe0100 && cp <= 0xe01ef) continue; if (ZERO_WIDTH.test(String.fromCodePoint(cp))) continue; width += isWideCodePoint(cp) ? 2 : 1; } return width; } function visibleWidth(s) { return displayWidth(s); } /** Longest prefix of `plain` (unstyled) that fits in `cols` cells. */ function sliceToWidth(plain, cols) { let width = 0; for (let i = 0; i < plain.length;) { const cp = plain.codePointAt(i); const size = cp > 0xffff ? 2 : 1; const next = displayWidth(String.fromCodePoint(cp)); if (width + next > cols) return plain.slice(0, i); width += next; i += size; } return plain; } export function padToWidth(s, width) { if (width <= 0) return ""; if (width === 1) return truncateLine(s, width); const visible = displayWidth(s); if (visible > width) return sliceToWidth(stripAnsi(s), width - 1) + "…"; return s + " ".repeat(width - visible); } // ANSI handling: `s` may contain ANSI escape codes (styled text from the style.* // helpers). We measure visible width via displayWidth, then if truncation is needed // we slice the *plain* text (discarding ANSI codes) to avoid cutting mid-escape. A // trailing reset is appended to guard against any residual SGR state from earlier // output on the same terminal line. export function truncateLine(s, cols) { if (cols <= 0) return ""; if (cols === 1) return "…" + "\x1b[0m"; if (displayWidth(s) <= cols) return s; return sliceToWidth(stripAnsi(s), cols - 1) + "…" + "\x1b[0m"; } // Reserve one column to avoid terminal autowrap when a line exactly fills the width. // Many terminals wrap on the last visible column, which corrupts full-screen redraws. export function renderWidth(columns = process.stdout.columns || 80) { return Math.max(1, columns - 1); } export function wrapSegments(segments, cols, opts = {}) { const indent = opts.indent ?? " "; const maxLines = Math.max(1, opts.maxLines ?? Number.POSITIVE_INFINITY); const separator = opts.separator ?? " "; const indentWidth = visibleWidth(indent); const available = Math.max(1, cols - indentWidth); const lines = []; let current = indent; let currentWidth = indentWidth; const pushEllipsis = () => { const extraSep = currentWidth > indentWidth ? separator : ""; lines.push(truncateLine(current + extraSep + "…", cols)); }; for (const raw of segments) { if (!raw) continue; const segment = truncateLine(raw, available); const segmentWidth = visibleWidth(segment); const separatorWidth = currentWidth > indentWidth ? visibleWidth(separator) : 0; if (currentWidth > indentWidth && currentWidth + separatorWidth + segmentWidth > cols) { if (lines.length + 1 >= maxLines) { pushEllipsis(); return lines.join("\n"); } lines.push(current); current = indent + segment; currentWidth = indentWidth + segmentWidth; continue; } if (currentWidth > indentWidth) { current += separator; currentWidth += separatorWidth; } current += segment; currentWidth += segmentWidth; } lines.push(current); return lines.slice(0, maxLines).join("\n"); } // ── Phren theme ──────────────────────────────────────────────────────────── // The wordmark and its gradient live with the splash text effects. import { PHREN_GRADIENT, PHREN_LOGO } from "./logo-fx.js"; // Apply gradient coloring across non-whitespace characters export function gradient(text, colors = PHREN_GRADIENT) { const plain = stripAnsi(text); const chars = [...plain]; const nonSpaceCount = chars.filter(ch => !/\s/.test(ch)).length; if (!nonSpaceCount || !colors.length) return text; let result = ""; let vi = 0; for (const ch of chars) { if (/\s/.test(ch)) { result += ch; } else { const ci = Math.min(Math.floor(vi * colors.length / nonSpaceCount), colors.length - 1); result += colors[ci] + ch; vi++; } } return result + RESET; } import { PHREN_ART as PHREN_STARTUP_ART } from "../phren-art.js"; // ── Line-based viewport: edge-triggered scroll (stable, no jumpiness) ───────── export function lineViewport(allLines, cursorFirstLine, cursorLastLine, height, prevStart) { if (allLines.length === 0 || height <= 0) return { lines: [], scrollStart: 0 }; if (allLines.length <= height) return { lines: allLines.slice(), scrollStart: 0 }; const first = Math.max(0, Math.min(cursorFirstLine, allLines.length - 1)); const last = Math.max(first, Math.min(cursorLastLine, allLines.length - 1)); let start = Math.max(0, prevStart); // Scroll up if cursor is above viewport if (first < start) start = first; // Scroll down if cursor is below viewport if (last >= start + height) start = last - height + 1; // Clamp start = Math.min(start, Math.max(0, allLines.length - height)); return { lines: allLines.slice(start, start + height), scrollStart: start }; } // ── Help text ──────────────────────────────────────────────────────────────── export function shellHelpText() { const hdr = (s) => style.bold(s); const k = (s) => style.boldCyan(s); const d = (s) => style.dim(s); const cmd = (s) => style.cyan(s); return [ "", hdr("Navigation"), ` ${k("← →")} ${d("switch tabs")} ${k("↑ ↓")} ${d("move cursor")} ${k("↵")} ${d("activate")} ${k("q")} ${d("quit")}`, ` ${k("/")} ${d("filter")} ${k(":")} ${d("command palette")} ${k("Esc")} ${d("cancel / clear filter")} ${k("?")} ${d("toggle this help")}`, "", hdr("View-specific keys"), ` ${style.bold("Projects")} ${k("↵")} ${d("open project tasks")} ${k("e")}${d("/")}${k("E")} ${d("edit AGENTS.md")} ${k("i")} ${d("cycle intro mode")}`, ` ${style.bold("Tasks")} ${k("a")} ${d("add task")} ${k("d")} ${d("toggle active/queue")} ${k("↵")} ${d("mark complete")}`, ` ${style.bold("Findings")} ${k("a")} ${d("tell phren")} ${k("d")} ${d("delete selected")}`, ` ${style.bold("Review Queue")} ${k("↵")} ${d("inspect selected item")} ${d("(read-only)")}`, ` ${style.bold("Graph")} ${k("↑↓←→")} ${d("walk")} ${k("1-9")} ${d("neighbour")} ${k("↵")} ${d("select / focus")} ${k("␣")} ${d("read in a bubble")} ${k("w")} ${d("watch")}`, ` ${k("v")} ${d("orbit: the graph as a sphere")} ${k("drag")} ${d("turn")} ${k("wheel")} ${d("zoom")} ${k("click")} ${d("select")} ${k("HJKL")} ${d("turn without a mouse")}`, ` ${" ".repeat(13)}${k("/")} ${d("search")} ${k("n N")} ${d("next / prev hit")} ${k("f")} ${d("filter")} ${k("[ ]")} ${d("project")}`, ` ${" ".repeat(13)}${k("+ -")} ${d("zoom")} ${k("0")} ${d("fit")} ${k("r")} ${d("re-lay out")} ${k("⇧↑↓←→")} ${d("pan")} ${k("o")} ${d("3D viewer")}`, ` ${style.bold("Skills")} ${k("e")} ${d("edit in $EDITOR")} ${k("E")} ${d("edit here")} ${k("t")} ${d("toggle enabled")} ${k("d")} ${d("remove")}`, "", "", hdr("Editor (E, or e for $EDITOR)"), ` ${k("h j k l")} ${d("move")} ${k("w b")} ${d("word")} ${k("0 $")} ${d("line")} ${k("gg G")} ${d("file")}`, ` ${k("i a I A")} ${d("insert")} ${k("o O")} ${d("open line")} ${k("x")} ${d("delete char")} ${k("dd")} ${d("delete line")}`, ` ${k("yy")} ${d("yank")} ${k("p P")} ${d("paste")} ${k("u")} ${d("undo")} ${k("/")} ${d("search")} ${k("n N")} ${d("next / prev")}`, ` ${k(":w")} ${d("write")} ${k(":q")} ${d("quit")} ${k(":wq")} ${d("write and quit")} ${k(":q!")} ${d("discard")}`, "", hdr("Palette commands (:cmd)"), ` ${cmd(":open <project>")} ${d("set active project context")}`, ` ${cmd(":add <task>")} ${d("add task")}`, ` ${cmd(":complete <id|match>")} ${d("mark done")}`, ` ${cmd(":move <id|match> <active|queue|done>")} ${d("move item")}`, ` ${cmd(":reprioritize <id|match> <high|medium|low>")}`, ` ${cmd(":context <id|match> <text>")}`, ` ${cmd(":pin <id>")} ${cmd(":unpin <id>")} ${cmd(":work next")} ${cmd(":tidy [keep]")}`, ` ${cmd(":find add <text>")} ${cmd(":find remove <id|match>")}`, ` ${cmd(":intro always|once-per-version|off")}`, ` ${cmd(":review queue")} ${d("inspect review queue (read-only)")}`, ` ${cmd(":govern")} ${cmd(":consolidate")} ${cmd(":search <query>")}`, ` ${cmd(":undo")} ${cmd(":diff")} ${cmd(":conflicts")} ${cmd(":reset")}`, ` ${cmd(":run fix")} ${cmd(":relink")} ${cmd(":rerun hooks")} ${cmd(":update")}`, ` ${cmd(":machines")}`, ].join("\n"); } // ── Terminal control ────────────────────────────────────────────────────────── export function clearScreen() { if (process.stdout.isTTY) { // Move cursor to home and overwrite in place (no full clear = no flicker) process.stdout.write("\x1b[H"); } } // Clear any leftover lines below the rendered content export function clearToEnd() { if (process.stdout.isTTY) { process.stdout.write("\x1b[J"); } } // Begin/End Synchronized Update (DEC private mode 2026). Terminals that support // it buffer everything between the two and present the frame in one go, so a // multi-kilobyte repaint never shows half-drawn. Terminals that don't support it // ignore unknown private modes, so this is safe to send unconditionally. const SYNC_BEGIN = "\x1b[?2026h"; const SYNC_END = "\x1b[?2026l"; const HIDE_CURSOR = "\x1b[?25l"; const SHOW_CURSOR = "\x1b[?25h"; // Autowrap off. The views already truncate to renderWidth(), but a single // mismeasured line would otherwise wrap, push every row below it down, and // scroll the frame out from under the cursor-home redraw — corrupting every // subsequent frame. With autowrap off an over-long line is merely clipped. const WRAP_OFF = "\x1b[?7l"; const WRAP_ON = "\x1b[?7h"; const ALT_SCREEN_ON = "\x1b[?1049h"; const ALT_SCREEN_OFF = "\x1b[?1049l"; /** Switch to the alternate screen buffer and put the terminal in TUI mode. */ export function enterFullscreen() { if (!process.stdout.isTTY) return; process.stdout.write(ALT_SCREEN_ON); process.stdout.write(HIDE_CURSOR); process.stdout.write(WRAP_OFF); } /** Undo enterFullscreen(). Safe to call more than once. */ export function exitFullscreen() { if (!process.stdout.isTTY) return; process.stdout.write(WRAP_ON); process.stdout.write(SHOW_CURSOR); process.stdout.write(ALT_SCREEN_OFF); } /** * Write one frame as a single synchronized write. * * The old path issued three separate writes (home, body, erase-to-end) with the * cursor visible, which let a concurrent repaint interleave between them and * dragged the caret across the screen on every keystroke. */ export function paintFrame(frame) { // A frame with more lines than the terminal has rows scrolls the alternate // buffer, and once it scrolls every later cursor-home repaint lands on // shifted rows and the frames stack on top of each other. Clip the overflow // (including a trailing newline on the last row) rather than scroll. const rows = Math.max(1, process.stdout.rows || 24); const lines = frame.split("\n"); const body = lines.length > rows ? lines.slice(0, rows).join("\n") : frame; process.stdout.write(SYNC_BEGIN + "\x1b[H" + body + "\x1b[J" + SYNC_END); } // The character art is a fixed-size pixel grid; the gap keeps the logo off it. const ART_WIDTH = 24; const ART_GAP = 2; const LOGO_WIDTH = Math.max(...PHREN_LOGO.map(displayWidth)); /** * The wordmark line, split onto two rows when the terminal is too narrow to * carry the tagline alongside the version badge. */ function startupInfoLines(version, available, taglineText = "local memory for working agents") { const tagline = style.dim(taglineText); const head = `${gradient("◆")} ${style.bold("phren")} ${badge(`v${version}`, style.boldBlue)}`; const single = `${head} ${tagline}`; return displayWidth(single) <= available ? [single] : [head, tagline]; } /** * Lay out one splash frame from the character art the caller supplies (a live * animation frame, or the static art). * * Every branch is chosen so the result fits the terminal it is about to be * painted into: the wide layout needs room for art + gap + logo, the stacked * one drops the logo rather than clipping it, and a terminal too short for the * character drops the character rather than pushing the tagline off the bottom. * A frame taller than the screen would scroll the alternate buffer, and once it * scrolls every later cursor-home repaint lands on shifted rows, stacking the * frames on top of each other. */ export function composeStartupFrame(artLines, version, hint, logo, tagline) { const cols = renderWidth(); const rows = process.stdout.rows || 24; // A text-effect frame of the wordmark may be supplied; otherwise the plain gradient. const logoLines = logo ?? PHREN_LOGO.map(line => gradient(line)); const hintLines = hint ? ["", ` ${hint}`] : []; // One blank line above, one below, plus the hint block. const budget = rows - 2 - hintLines.length; const stackedInfo = startupInfoLines(version, cols - 2, tagline).map(line => ` ${line}`); const sideBySide = () => { // Logo is 6 lines; the leading blanks centre it against the character. const rightSide = ["", "", ...logoLines, "", ...startupInfoLines(version, cols - ART_WIDTH - ART_GAP, tagline)]; const lines = []; for (let i = 0; i < Math.max(artLines.length, rightSide.length); i++) { // padToWidth measures display cells; String.padEnd counts the art's ANSI // bytes as width and pads nothing at all. const left = padToWidth(i < artLines.length ? artLines[i] : "", ART_WIDTH + ART_GAP); lines.push(left + (i < rightSide.length ? rightSide[i] : "")); } return lines; }; // Widest layout the terminal can carry, then progressively less of it: art // beside the logo, art alone, logo alone, and finally just the wordmark. const candidates = []; if (cols >= ART_WIDTH + ART_GAP + LOGO_WIDTH) candidates.push(sideBySide()); candidates.push([...artLines, "", ...stackedInfo]); if (cols >= LOGO_WIDTH + 2) candidates.push([...logoLines.map(line => ` ${line}`), "", ...stackedInfo]); candidates.push(stackedInfo); const body = candidates.find(lines => lines.length <= budget) ?? stackedInfo; return ["", ...body, ...hintLines, ""]; } /** * Clamp a frame to the terminal before painting: truncate each line to the * render width, erase whatever the previous frame left to the right of it, and * drop rows past the bottom so the frame can never scroll the screen. */ export function fitFrame(lines) { const cols = renderWidth(); const rows = Math.max(1, process.stdout.rows || 24); return lines .slice(0, rows) .map(line => truncateLine(line, cols) + "\x1b[K") .join("\n"); } export function shellStartupFrames(version, tagline) { const cols = process.stdout.columns || 80; if (cols >= 44) { return [fitFrame(composeStartupFrame(PHREN_STARTUP_ART, version, undefined, undefined, tagline))]; } // Narrow terminal: progressive text reveal with gradient const stages = ["p", "phr", "phren"]; const spinners = ["◜", "◠", "◝"]; const infoLines = startupInfoLines(version, renderWidth() - 2, tagline).map(line => ` ${line}`); return stages.map((stage, i) => fitFrame([ "", ` ${gradient(stage)} ${style.dim(spinners[i])}`, "", ...infoLines, "", ])); }