UNPKG

@phren/cli

Version:

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

768 lines (767 loc) • 40.8 kB
import { taskReadiness, filterTaskDoc, taskCounts } from "../data/task-contract.js"; import { moduleEnabled } from "../modules/runtime.js"; /** * View rendering functions for the phren interactive shell. * Extracted from shell.ts to keep the orchestrator under 300 lines. */ import { projectMemoryCounts } from "../content/summarize.js"; import * as fs from "fs"; import * as path from "path"; import { canonicalTaskFilePath, listProjectCards, readTasks, readFindings, readReviewQueue, readRuntimeHealth, resolveTaskFilePath, } from "../data/access.js"; import { style, badge, separator, stripAnsi, truncateLine, displayWidth, renderWidth, wrapSegments, lineViewport, shellHelpText, gradient, } from "./render.js"; import { formatSelectableLine, viewportWithStatus, } from "./view-list.js"; import { SUB_VIEWS, enabledSubViews, TAB_ICONS, } from "./types.js"; import { tasksByFilter, queueByFilter, } from "./palette.js"; import { listMachines, listProfiles, } from "../data/access.js"; import { readInstallPreferences } from "../init/preferences.js"; import { isProjectHookEnabled, readProjectConfig } from "../project-config.js"; import { getScopedSkills } from "../skill/registry.js"; import { errorMessage } from "../utils.js"; import { resolveProjectStorePath } from "../cli/namespaces-utils.js"; import { logger } from "../logger.js"; import { renderGraphView, graphSummary } from "./graph/graph-view.js"; // ── Tab bar ──────────────────────────────────────────────────────────────── /** * The whole top of the frame in as few rows as possible: brand, current view, * project and filter on one line, then a rule. On a 24-row terminal the old * three-row header cost more than a tenth of the screen. */ function renderTopBar(state, summary = "", views = SUB_VIEWS) { const cols = renderWidth(); const brand = gradient("◆ phren"); const dot = style.dim("·"); const project = state.project ? `${dot} ${style.cyan(state.project)}` : ""; const filter = state.filter ? `${dot} ${style.yellow("/" + state.filter)}` : ""; const isSub = SUB_VIEWS.includes(state.view); if (!isSub) { const label = style.boldMagenta(`${TAB_ICONS[state.view] ?? "◆"} ${state.view}`); const line = [" " + brand, label, project, filter, summary ? `${dot} ${summary}` : ""].filter(Boolean).join(" "); return `${truncateLine(line, cols)}\n${separator(cols)}`; } // Sub-views carry a tab strip. Try full labels, then icons for the inactive // tabs, before giving up and taking a second row. const head = ` ${brand}${project ? ` ${project}` : ""}${filter ? ` ${filter}` : ""}`; const full = views.map((v) => (v === state.view ? style.boldMagenta(`${TAB_ICONS[v]} ${v}`) : style.dim(`${TAB_ICONS[v]} ${v}`))); const terse = views.map((v) => (v === state.view ? style.boldMagenta(`${TAB_ICONS[v]} ${v}`) : style.dim(TAB_ICONS[v] ?? ""))); for (const tabs of [full, terse]) { const merged = `${head} ${style.dim("│")} ${tabs.join(style.dim(" │ "))}`; if (displayWidth(merged) <= cols) return `${merged}\n${separator(cols)}`; } const tabLine = wrapSegments(full, cols, { indent: " ", maxLines: 2, separator: style.dim("│") }); return `${head}\n${tabLine}\n${separator(cols)}`; } // ── Bottom bar ───────────────────────────────────────────────────────────── function renderBottomBar(state, navMode, inputCtx, inputBuf) { const cols = renderWidth(); const sep = separator(cols); const dot = style.dim(" · "); const k = (s) => style.boldCyan(s); const d = (s) => style.dim(s); if (navMode === "input") { const labels = { filter: "filter", command: "cmd", add: "add task", "learn-add": "add finding", "skill-add": "new skill name", "graph-search": "search graph", }; const label = labels[inputCtx] || inputCtx; return `${sep}\n ${style.boldCyan(label + " ›")} ${inputBuf}${style.cyan("█")}`; } // Only what you reach for constantly. Everything else is one `?` away, so // the bar never costs more than a single row. const essentials = { Projects: [`${k("↑↓")} ${d("move")}`, `${k("↵")} ${d("open")}`, `${k("e")} ${d("edit AGENTS.md")}`], Tasks: [`${k("↑↓")} ${d("move")}`, `${k("a")} ${d("add")}`, `${k("↵")} ${d("done")}`], Findings: [`${k("↑↓")} ${d("move")}`, `${k("a")} ${d("add")}`], "Review Queue": [`${k("↑↓")} ${d("move")}`, `${k("↵")} ${d("inspect")}`], Skills: [`${k("↑↓")} ${d("move")}`, `${k("e")} ${d("edit")}`, `${k("t")} ${d("toggle")}`], Hooks: [`${k("↑↓")} ${d("move")}`, `${k("a")} ${d("enable")}`], Health: [`${k("↑↓")} ${d("scroll")}`], Graph: [`${k("↑↓←→")} ${d("walk")}`, `${k("↵")} ${d("select")}`, `${k("␣")} ${d("read")}`, `${k("v")} ${d("orbit")}`, `${k("w")} ${d("watch")}`, `${k("a")} ${d("agents")}`], }; const search = state.view === "Graph" ? `${k("/")} ${d("search")}` : `${k("/")} ${d("filter")}`; // `? keys` and `q quit` are the two you cannot afford to lose, so a narrow // terminal drops view hints off the end instead of truncating those away. const always = [`${k("?")} ${d("keys")}`, `${k("q")} ${d("quit")}`]; const optional = [...(essentials[state.view] ?? []), search]; let line = ""; for (let take = optional.length; take >= 0; take--) { line = wrapSegments([...optional.slice(0, take), ...always], cols, { indent: " ", maxLines: 1, separator: dot }); if (!stripAnsi(line).trimEnd().endsWith("…")) break; } return `${sep}\n${line}`; } // ── Content height ───────────────────────────────────────────────────────── function countRenderedLines(block) { return block.split("\n").length; } function contentHeight(topBar, bottomBar, hasMessage) { const rows = process.stdout.rows || 24; const reserved = countRenderedLines(topBar) + countRenderedLines(bottomBar) + (hasMessage ? 1 : 0); return Math.max(4, rows - reserved); } function collectProjectDashboardEntries(ctx) { const cards = listProjectCards(ctx.phrenPath, ctx.profile); return cards.map((card) => { if (card.name === "global") { return { ...card, activeCount: 0, queueCount: 0, findingCount: 0, reviewCount: 0, }; } const storePath = resolveProjectStorePath(ctx.phrenPath, card.name); const task = readTasks(storePath, card.name); const findings = readFindings(storePath, card.name); const review = readReviewQueue(storePath, card.name); return { ...card, activeCount: task.ok ? task.data.items.Active.length : 0, queueCount: task.ok ? task.data.items.Queue.length : 0, // Everything the project holds, archive included, as every graph shows it. findingCount: findings.ok ? projectMemoryCounts(storePath, card.name).findings : 0, reviewCount: review.ok ? review.data.length : 0, }; }); } function renderProjectsDashboard(ctx, entries, height) { const tasksEnabled = moduleEnabled(ctx.phrenPath, "tasks", ctx.profile); const runtime = readRuntimeHealth(ctx.phrenPath); const scoped = entries.filter((entry) => entry.name !== "global"); const totals = scoped.reduce((acc, entry) => { acc.active += entry.activeCount; acc.queue += entry.queueCount; acc.findings += entry.findingCount; acc.review += entry.reviewCount; return acc; }, { active: 0, queue: 0, findings: 0, review: 0 }); const activePreview = scoped .filter((entry) => entry.activeCount > 0 || entry.queueCount > 0) .slice(0, 3) .map((entry) => `${style.bold(entry.name)} ${style.dim(`A${entry.activeCount} · Q${entry.queueCount}`)}`); const findingsPreview = scoped .filter((entry) => entry.findingCount > 0) .slice(0, 3) .map((entry) => `${style.bold(entry.name)} ${style.dim(`${entry.findingCount} findings`)}`); const lines = [ ` ${badge(ctx.profile || "default", style.boldBlue)} ${style.bold(String(scoped.length))} projects ${style.dim("·")} ${tasksEnabled ? `${style.boldGreen(String(totals.active))} active ${style.dim("·")} ${style.boldYellow(String(totals.queue))} queued ${style.dim("·")} ` : ""}${style.boldCyan(String(totals.findings))} findings ${style.dim("·")} ${style.boldMagenta(String(totals.review))} review`, ctx.state.project ? ` ${style.green("●")} active context ${style.boldCyan(ctx.state.project)} ${style.dim(tasksEnabled ? "· ↵ opens selected project tasks" : "· ↵ opens selected project findings")}` : ` ${style.dim("No project selected yet")} ${style.dim(tasksEnabled ? "· ↵ sets context and opens tasks" : "· ↵ sets context and opens findings")}`, ` ${style.dim("Sync")} ${style.dim(runtime.lastSync?.lastPushStatus || runtime.lastAutoSave?.status || "unknown")} ${style.dim("·")} ${style.dim("unsynced")} ${style.bold(String(runtime.lastSync?.unsyncedCommits ?? 0))} ${style.dim("·")} ${style.dim("intro")} ${style.cyan(ctx.state.introMode || "once-per-version")}`, ]; if (height >= 12) { lines.push(""); if (tasksEnabled) lines.push(` ${style.bold("Task pulse")} ${activePreview.length ? activePreview.join(style.dim(" · ")) : style.dim("No active tasks across this profile.")}`); lines.push(` ${style.bold("Recent fragments")} ${findingsPreview.length ? findingsPreview.join(style.dim(" · ")) : style.dim("Nothing yet.")}`); } lines.push(""); lines.push(` ${style.bold("Projects")} ${style.dim("press ↵ to open a project, / to filter, :intro to tune startup")}`); return lines; } function renderProjectsView(ctx, cursor, height) { const cols = renderWidth(); const cards = collectProjectDashboardEntries(ctx); const filtered = ctx.state.filter ? cards.filter((c) => `${c.name} ${c.summary} ${c.docs.join(" ")}`.toLowerCase().includes(ctx.state.filter.toLowerCase())) : cards; if (!filtered.length) { return [style.dim(" No projects in this profile.")]; } const dashboardLines = renderProjectsDashboard(ctx, cards, height); const listHeight = Math.max(4, height - dashboardLines.length); const allLines = []; let cursorFirstLine = 0; let cursorLastLine = 0; for (let absIdx = 0; absIdx < filtered.length; absIdx++) { const card = filtered[absIdx]; const isSelected = absIdx === cursor; if (isSelected) cursorFirstLine = allLines.length; const isActive = card.name === ctx.state.project; const cursorChar = isSelected ? style.cyan("▶") : " "; const bullet = isActive ? style.green("●") : style.dim("○"); const nameStr = isActive ? style.boldGreen(card.name) : style.bold(card.name); const docsStr = style.dim(`[${moduleEnabled(ctx.phrenPath, "tasks", ctx.profile) ? `A${card.activeCount} · Q${card.queueCount} · ` : ""}F${card.findingCount} · R${card.reviewCount}]`); const storeStr = card.store ? ` ${style.dim("·")} ${style.cyan(card.store)}` : ""; let nameRow = ` ${cursorChar} ${bullet} ${nameStr} ${docsStr}${storeStr}`; let summaryRow = ` ${style.dim(card.summary || "No summary yet.")}`; if (isSelected) { nameRow = formatSelectableLine(nameRow, cols, true); summaryRow = formatSelectableLine(summaryRow, cols, true); } allLines.push(nameRow); allLines.push(summaryRow); if (isSelected) cursorLastLine = allLines.length - 1; } const usableHeight = Math.max(1, listHeight - (allLines.length > listHeight ? 1 : 0)); const vp = viewportWithStatus(allLines, cursorFirstLine, cursorLastLine, usableHeight, ctx.currentScroll(), cursor, filtered.length); ctx.setScroll(vp.scrollStart); return [...dashboardLines, ...vp.lines]; } // ── Task helpers ──────────────────────────────────────────────────────── function sectionBullet(title) { switch (title) { case "Active": return { bullet: style.green("●"), colorFn: style.boldGreen }; case "Queue": return { bullet: style.yellow("●"), colorFn: style.boldYellow }; case "Done": return { bullet: style.gray("●"), colorFn: style.dim }; default: return { bullet: "●", colorFn: style.bold }; } } function priorityIndicator(priority, isDone) { if (isDone) return style.dim("○"); switch (priority) { case "high": return style.boldRed("●"); case "medium": return style.yellow("◐"); case "low": return style.dim("○"); default: return style.dim("·"); } } function taskStatusIcon(section, checked) { if (checked || section === "Done") return style.dim("✓"); if (section === "Active") return style.magenta("◉"); return style.dim("☐"); } const BID_RE = /<!--\s*bid:([a-z0-9]{8})\s*-->/; function parseSubsections(taskPath, project, cache) { if (cache?.project === project) return { map: cache.map, cache }; const map = new Map(); try { const raw = fs.readFileSync(taskPath, "utf8"); let currentSub = ""; let rowIdx = 0; for (const line of raw.split("\n")) { const subMatch = line.match(/^###\s+(.+)/); if (subMatch) { currentSub = subMatch[1].trim(); continue; } if (line.match(/^##\s/)) { currentSub = ""; continue; } if (line.startsWith("- ")) { if (currentSub) { const bidMatch = line.match(BID_RE); const key = bidMatch ? bidMatch[1] : `row:${rowIdx}`; map.set(key, currentSub); } rowIdx++; } } } catch (err) { logger.debug("shell-view", `buildSubsectionMap: ${errorMessage(err)}`); } const newCache = { project, map }; return { map, cache: newCache }; } // ── Tasks view ───────────────────────────────────────────────────────────── function renderTaskView(ctx, cursor, height, subsectionsCache) { const cols = renderWidth(); const project = ctx.state.project; if (!project) { return { lines: [style.dim(" No project selected — navigate to Projects (← →) and press ↵")], subsectionsCache }; } const storePath = resolveProjectStorePath(ctx.phrenPath, project); const result = readTasks(storePath, project); if (!result.ok) return { lines: [result.error], subsectionsCache }; const parsed = filterTaskDoc(ctx.phrenPath, result.data, { responsibility: ctx.state.taskResponsibility }); const counts = taskCounts(ctx.phrenPath, result.data); const laneHeader = ` ${ctx.state.taskResponsibility ?? "Human + Agent"} · Human ${counts.human} · Agent ready ${counts.agentReady} · Waiting ${counts.agentWaitingOnHuman + counts.agentWaitingOnTask} (:lane human|agent|all)`; const warnings = parsed.issues.length ? [` ${style.yellow("⚠")} ${style.yellow(parsed.issues.join("; "))}`, ""] : []; const taskFile = resolveTaskFilePath(storePath, project) ?? canonicalTaskFilePath(storePath, project) ?? path.join(storePath, project, "tasks.md"); const subsResult = parseSubsections(taskFile, project, subsectionsCache); const subsections = subsResult.map; const newCache = subsResult.cache; const active = ctx.state.filter ? tasksByFilter(parsed.items.Active, ctx.state.filter) : parsed.items.Active; const queue = ctx.state.filter ? tasksByFilter(parsed.items.Queue, ctx.state.filter) : parsed.items.Queue; const done = ctx.state.filter ? tasksByFilter(parsed.items.Done, ctx.state.filter) : parsed.items.Done; const flatItems = [...active, ...queue, ...done]; if (!flatItems.length) { const hint = ctx.state.filter ? " No items match the filter." : ` No tasks yet. Press ${style.boldCyan("a")} to add one.`; return { lines: [...warnings, style.dim(hint)], subsectionsCache: newCache }; } const queueStart = active.length; const doneStart = active.length + queue.length; const allLines = [style.dim(laneHeader)]; let cursorFirstLine = 0; let cursorLastLine = 0; let lastSection = ""; let lastSub = ""; for (let absIdx = 0; absIdx < flatItems.length; absIdx++) { const item = flatItems[absIdx]; const isSelected = absIdx === cursor; const isDone = absIdx >= doneStart; const section = absIdx < queueStart ? "Active" : absIdx < doneStart ? "Queue" : "Done"; if (section !== lastSection) { lastSection = section; lastSub = ""; const { bullet, colorFn } = sectionBullet(section); if (allLines.length > 0) allLines.push(""); allLines.push(` ${bullet} ${colorFn(section)}`); } const sub = (item.stableId ? subsections.get(item.stableId) : undefined) ?? subsections.get(`row:${absIdx}`) ?? ""; if (sub && sub !== lastSub) { lastSub = sub; allLines.push(` ${style.boldYellow(sub)}`); } if (isSelected) cursorFirstLine = allLines.length; const prioIcon = priorityIndicator(item.priority, isDone); const statusIcon = taskStatusIcon(section, item.checked); const pinTag = item.pinned ? ` ${style.boldCyan("★")}` : ""; const ghTag = item.githubIssue ? ` ${style.dim("[")}${style.cyan(`#${item.githubIssue}`)}${style.dim("]")}` : ""; const lineText = isDone ? style.dim(item.line) : item.line; const idStr = style.dim(item.id.padEnd(3)); const ready = taskReadiness(ctx.phrenPath, result.data, item); const laneTag = ` [${ready.responsibility} · ${ready.readiness}]`; let row = ` ${prioIcon} ${statusIcon} ${idStr} ${lineText}${pinTag}${ghTag}${laneTag}`; row = isSelected && !isDone ? formatSelectableLine(row, cols, true) : truncateLine(row, cols); allLines.push(row); for (const prerequisite of ready.prerequisites) { const responsibility = prerequisite.missing ? "Unknown responsibility" : prerequisite.responsibility === "human" ? "Human" : "Agent"; allLines.push(truncateLine(` ${prerequisite.missing ? "Unavailable" : prerequisite.completed ? "✓" : "Waiting on"} [${responsibility}] ${prerequisite.title}`, cols)); // Identity components are validated ASCII. Wrap rather than truncate so // similarly titled cross-store tasks stay distinguishable in narrow panes. const identity = `${prerequisite.storeId}/${prerequisite.project}/${prerequisite.stableId}`; const indent = " ".repeat(Math.min(14, Math.max(0, cols - 1))); const width = Math.max(1, cols - indent.length); for (let start = 0; start < identity.length; start += width) allLines.push(indent + identity.slice(start, start + width)); } if (item.context) { const ctxLine = ` ${style.dimItalic("→ " + item.context)}`; allLines.push(isSelected && !isDone ? formatSelectableLine(ctxLine, cols, true) : truncateLine(ctxLine, cols)); } if (isSelected) cursorLastLine = allLines.length - 1; } const usableHeight = Math.max(1, height - warnings.length - (allLines.length > height ? 1 : 0)); const vp = viewportWithStatus(allLines, cursorFirstLine, cursorLastLine, usableHeight, ctx.currentScroll(), cursor, active.length + queue.length); ctx.setScroll(vp.scrollStart); return { lines: [...warnings, ...vp.lines], subsectionsCache: newCache }; } // ── Findings view ────────────────────────────────────────────────────────── function renderFindingsView(ctx, cursor, height) { const cols = renderWidth(); const project = ctx.state.project; if (!project) return [style.dim(" No project selected.")]; const storePath = resolveProjectStorePath(ctx.phrenPath, project); const result = readFindings(storePath, project); if (!result.ok) return [result.error]; const all = result.data; const filtered = ctx.state.filter ? all.filter((item) => `${item.id} ${item.date} ${item.text}`.toLowerCase().includes(ctx.state.filter.toLowerCase())) : all; if (!filtered.length) { return [style.dim(` Nothing here yet. Press ${style.boldCyan("a")} to tell phren something.`)]; } const allLines = []; let cursorFirstLine = 0; let cursorLastLine = 0; for (let absIdx = 0; absIdx < filtered.length; absIdx++) { const item = filtered[absIdx]; const isSelected = absIdx === cursor; if (isSelected) cursorFirstLine = allLines.length; const idStr = style.dim(item.id.padEnd(4)); const dateStr = style.dim(`[${item.date}]`); let row = ` ${idStr} ${dateStr} ${item.text}`; row = formatSelectableLine(row, cols, isSelected); allLines.push(row); if (item.citation) { const cite = ` ${style.italic(style.blue("↗ " + item.citation))}`; allLines.push(formatSelectableLine(cite, cols, isSelected)); } if (isSelected) cursorLastLine = allLines.length - 1; } const usableHeight = Math.max(1, height - (allLines.length > height ? 1 : 0)); const vp = viewportWithStatus(allLines, cursorFirstLine, cursorLastLine, usableHeight, ctx.currentScroll(), cursor, filtered.length); ctx.setScroll(vp.scrollStart); return vp.lines; } // ── Review Queue view ────────────────────────────────────────────────────── function queueSectionBadge(section) { switch (section.toLowerCase()) { case "review": return badge(section, style.yellow); case "stale": return badge(section, style.red); case "conflicts": return badge(section, style.magenta); default: return badge(section, style.dim); } } function renderMemoryQueueView(ctx, cursor, height) { const cols = renderWidth(); const project = ctx.state.project; if (!project) return [style.dim(" No project selected.")]; const storePath = resolveProjectStorePath(ctx.phrenPath, project); const result = readReviewQueue(storePath, project); if (!result.ok) return [result.error]; const filtered = ctx.state.filter ? queueByFilter(result.data, ctx.state.filter) : result.data; if (!filtered.length) { return [style.dim(" No queued memory items. Run :govern to scan for stale entries.")]; } const allLines = []; let cursorFirstLine = 0; let cursorLastLine = 0; let currentSection = ""; for (let absIdx = 0; absIdx < filtered.length; absIdx++) { const item = filtered[absIdx]; const isSelected = absIdx === cursor; if (item.section !== currentSection) { currentSection = item.section; allLines.push(` ${queueSectionBadge(currentSection)} ${style.bold(currentSection)}`); } if (isSelected) cursorFirstLine = allLines.length; const riskBadge = item.risky ? badge("risk", style.boldRed) : badge("ok", style.green); const confStr = item.confidence !== undefined ? ` ${style.dim("conf=")}${item.confidence >= 0.8 ? style.green(item.confidence.toFixed(2)) : item.confidence >= 0.6 ? style.yellow(item.confidence.toFixed(2)) : style.red(item.confidence.toFixed(2))}` : ""; let metaRow = ` ${style.dim(item.id)} ${riskBadge} ${style.dim(`[${item.date}]`)}${confStr}`; let textRow = ` ${item.text}`; if (isSelected) { metaRow = formatSelectableLine(metaRow, cols, true); textRow = formatSelectableLine(textRow, cols, true); } else { metaRow = truncateLine(metaRow, cols); textRow = truncateLine(textRow, cols); } allLines.push(metaRow); allLines.push(textRow); if (isSelected) cursorLastLine = allLines.length - 1; } const usableHeight = Math.max(1, height - (allLines.length > height ? 1 : 0)); const vp = viewportWithStatus(allLines, cursorFirstLine, cursorLastLine, usableHeight, ctx.currentScroll(), cursor, filtered.length); ctx.setScroll(vp.scrollStart); return vp.lines; } export function getProjectSkills(phrenPath, project) { const storePath = resolveProjectStorePath(phrenPath, project); return getScopedSkills(storePath, "", project).map((skill) => ({ name: skill.name, path: skill.path, enabled: skill.enabled, // Carried so actions do not have to guess: a global skill toggled with the // project as its scope writes a key nothing reads, and a path recovered by // splitting a display string breaks on any path containing the separator. scopeType: skill.scopeType, storePath, })); } /** Max lines of skill content to show inline when selected. */ const SKILL_PREVIEW_LINES = 20; function readSkillBody(skillPath) { try { const raw = fs.readFileSync(skillPath, "utf8"); // Strip YAML frontmatter (--- ... ---) const stripped = raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const fmMatch = stripped.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/); const body = fmMatch ? fmMatch[1] : stripped; // Strip leading title (# ...) and blank lines const lines = body.split("\n"); let start = 0; while (start < lines.length && (lines[start].trim() === "" || lines[start].startsWith("# "))) start++; return lines.slice(start, start + SKILL_PREVIEW_LINES); } catch { return ["(could not read skill file)"]; } } function renderSkillsView(ctx, cursor, height) { const cols = renderWidth(); const project = ctx.state.project; if (!project) return [style.dim(" No project selected.")]; const skills = getProjectSkills(ctx.phrenPath, project); const filtered = ctx.state.filter ? skills.filter((s) => s.name.toLowerCase().includes(ctx.state.filter.toLowerCase())) : skills; if (!filtered.length) { return [style.dim(` No skills for ${project}. Use "phren skills add ${project} <path>" to add one.`)]; } const allLines = []; let cursorFirstLine = 0; let cursorLastLine = 0; for (let i = 0; i < filtered.length; i++) { const s = filtered[i]; const isSelected = i === cursor; if (isSelected) cursorFirstLine = allLines.length; const isSymlink = (() => { try { return fs.lstatSync(s.path).isSymbolicLink(); } catch { return false; } })(); const linkTag = isSymlink ? style.dim(" →") : ""; const status = s.enabled ? style.boldGreen("enabled ") : style.dim("disabled"); let row = ` ${style.dim((i + 1).toString().padEnd(3))} ${status} ${style.bold(s.name)}${linkTag}`; row = formatSelectableLine(row, cols, isSelected); allLines.push(row); // Show inline content preview for the selected skill if (isSelected) { const bodyLines = readSkillBody(s.path); if (bodyLines.length > 0) { allLines.push(""); for (const line of bodyLines) { allLines.push(truncateLine(` ${style.dim(line)}`, cols)); } allLines.push(""); } } if (isSelected) cursorLastLine = allLines.length - 1; } const usableHeight = Math.max(1, height - (allLines.length > height ? 1 : 0)); const vp = viewportWithStatus(allLines, cursorFirstLine, cursorLastLine, usableHeight, ctx.currentScroll(), cursor, filtered.length); ctx.setScroll(vp.scrollStart); return vp.lines; } const LIFECYCLE_HOOKS = [ { event: "UserPromptSubmit", description: "inject context before each prompt" }, { event: "Stop", description: "phren saves findings after each response" }, { event: "SessionStart", description: "git pull at session start" }, ]; export function getHookEntries(phrenPath, project) { const prefs = readInstallPreferences(phrenPath); const hooksEnabled = prefs.hooksEnabled !== false; const storePath = project ? resolveProjectStorePath(phrenPath, project) : phrenPath; const projectConfig = project ? readProjectConfig(storePath, project) : undefined; return LIFECYCLE_HOOKS.map((h) => ({ ...h, enabled: hooksEnabled && isProjectHookEnabled(storePath, project, h.event, projectConfig), })); } function renderHooksView(ctx, cursor, height) { const cols = renderWidth(); const entries = getHookEntries(ctx.phrenPath, ctx.state.project); const allEnabled = entries.every((e) => e.enabled); const allLines = []; let cursorFirstLine = 0; let cursorLastLine = 0; for (let i = 0; i < entries.length; i++) { const e = entries[i]; const isSelected = i === cursor; if (isSelected) cursorFirstLine = allLines.length; const statusBadge = e.enabled ? style.boldGreen("active ") : style.dim("inactive"); let nameRow = ` ${style.dim((i + 1).toString().padEnd(3))} ${statusBadge} ${style.bold(e.event)}`; let descRow = ` ${style.dim(e.description)}`; if (isSelected) { nameRow = formatSelectableLine(nameRow, cols, true); descRow = formatSelectableLine(descRow, cols, true); } else { nameRow = truncateLine(nameRow, cols); descRow = truncateLine(descRow, cols); } allLines.push(nameRow); allLines.push(descRow); if (isSelected) cursorLastLine = allLines.length - 1; } allLines.push(""); allLines.push(style.dim(` hooks: ${allEnabled ? style.boldGreen("ON") : style.boldRed("OFF")} · ${style.dim("a = enable all · d = disable all")}`)); const usableHeight = Math.max(1, height - (allLines.length > height ? 1 : 0)); const vp = viewportWithStatus(allLines, cursorFirstLine, cursorLastLine, usableHeight, ctx.currentScroll(), cursor, entries.length); ctx.setScroll(vp.scrollStart); return vp.lines; } export { writeInstallPreferences } from "../init/preferences.js"; // ── Machines/Profiles view ───────────────────────────────────────────────── function renderMachinesView(phrenPath) { const machines = listMachines(phrenPath); const profiles = listProfiles(phrenPath); const lines = []; lines.push(style.bold(" Machines")); if (!machines.ok) { lines.push(` ${style.dim(machines.error)}`); } else { const entries = Object.entries(machines.data); if (!entries.length) lines.push(` ${style.dim("(none)")}`); for (const [machine, prof] of entries) { lines.push(` ${style.bold(machine)} ${style.dim("→")} ${style.cyan(prof)}`); } } lines.push("", style.bold(" Profiles")); if (!profiles.ok) { lines.push(` ${style.dim(profiles.error)}`); } else { if (!profiles.data.length) lines.push(` ${style.dim("(none)")}`); for (const prof of profiles.data) { lines.push(` ${style.cyan(prof.name)}: ${prof.projects.join(", ") || style.dim("(no projects)")}`); } } lines.push("", ` ${style.dim(":machine map <hostname> <profile>")}`, ` ${style.dim(":profile add-project|remove-project <profile> <project>")}`); return lines; } // ── Health view ──────────────────────────────────────────────────────────── function renderHealthView(phrenPath, doctor, cursor, height, currentScroll, setScroll) { const runtime = readRuntimeHealth(phrenPath); const allLines = []; const statusIcon = doctor.ok ? style.green("✓") : style.red("✗"); const statusLabel = doctor.ok ? style.boldGreen("healthy") : style.boldRed("issues found"); allLines.push(` ${statusIcon} ${style.bold("phren")} ${statusLabel}`); if (doctor.machine) allLines.push(` ${style.dim("machine:")} ${style.bold(doctor.machine)}`); if (doctor.profile) allLines.push(` ${style.dim("profile:")} ${style.cyan(doctor.profile)}`); allLines.push("", ` ${style.bold("Checks")}`); for (const check of doctor.checks) { const icon = check.ok ? style.green("✓") : style.red("✗"); const status = check.ok ? style.dim("ok") : style.boldRed("fail"); allLines.push(` ${icon} ${status} ${check.name}: ${check.detail}`); } allLines.push("", ` ${style.bold("Runtime")}`); allLines.push(` ${style.dim("last hook: ")} ${style.dim(runtime.lastPromptAt || "n/a")}`); allLines.push(` ${style.dim("last auto-save: ")} ${style.dim(runtime.lastAutoSave?.at || "n/a")} ${style.dim(runtime.lastAutoSave?.status || "")}`); allLines.push(` ${style.dim("last governance: ")} ${style.dim(runtime.lastGovernance?.at || "n/a")} ${style.dim(runtime.lastGovernance?.status || "")}`); allLines.push(` ${style.dim("last pull: ")} ${style.dim(runtime.lastSync?.lastPullAt || "n/a")} ${style.dim(runtime.lastSync?.lastPullStatus || "")}`); allLines.push(` ${style.dim("last push: ")} ${style.dim(runtime.lastSync?.lastPushAt || "n/a")} ${style.dim(runtime.lastSync?.lastPushStatus || "")}`); allLines.push(` ${style.dim("unsynced: ")} ${style.bold(String(runtime.lastSync?.unsyncedCommits ?? 0))} ${style.dim("commit(s)")}`); if (!doctor.ok) { allLines.push("", ` ${style.boldYellow("→")} ${style.bold(":run fix")} ${style.dim("to auto-heal")} ${style.dim(":relink :rerun hooks :update")}`); } else { allLines.push("", ` ${style.dim(":run fix :relink :rerun hooks :update")}`); } const lineCount = allLines.length; if (allLines.length <= height) return { lines: allLines, lineCount }; const cols = renderWidth(); const clampedCursor = Math.max(0, Math.min(cursor, allLines.length - 1)); allLines[clampedCursor] = formatSelectableLine(allLines[clampedCursor], cols, true); const vp = lineViewport(allLines, clampedCursor, clampedCursor, height - 1, currentScroll); setScroll(vp.scrollStart); const pct = allLines.length <= 1 ? 100 : Math.round((clampedCursor / (allLines.length - 1)) * 100); vp.lines.push(style.dim(` ━━━${clampedCursor + 1}/${allLines.length} ${pct}%`)); return { lines: vp.lines, lineCount }; } // ── Main render ──────────────────────────────────────────────────────────── export async function renderShell(ctx, navMode, inputCtx, inputBuf, showHelp, helpScroll, message, doctorSnapshot, subsectionsCache, setHealthLineCount, setSubsectionsCache) { const graphSummaryLine = ctx.state.view === "Graph" && ctx.graph ? graphSummary(ctx.graph()) : ""; const topBar = renderTopBar(ctx.state, graphSummaryLine, enabledSubViews(ctx.phrenPath, ctx.profile)); const bottomBar = renderBottomBar(ctx.state, navMode, inputCtx, inputBuf); const cursor = ctx.currentCursor(); // An empty message line used to hold a row open on every frame. const hasMessage = Boolean(stripAnsi(message).trim()); const height = contentHeight(topBar, bottomBar, hasMessage); let contentLines; if (showHelp) { // The help is longer than a small terminal, and used to be clipped in // silence — on 24 rows more than half of it simply was not there. const all = shellHelpText().split("\n").filter(line => moduleEnabled(ctx.phrenPath, "tasks", ctx.profile) || !/task/i.test(stripAnsi(line))); if (all.length <= height) { contentLines = all; } else { const maxScroll = all.length - (height - 1); const start = Math.max(0, Math.min(helpScroll, maxScroll)); contentLines = all.slice(start, start + height - 1); const atEnd = start >= maxScroll; contentLines.push(style.dim(` ━━━ ${start + 1}-${start + height - 1} of ${all.length} ${atEnd ? "↑ back" : "↑↓ scroll"}`)); } } else { switch (ctx.state.view) { case "Projects": contentLines = renderProjectsView(ctx, cursor, height); break; case "Tasks": { const result = renderTaskView(ctx, cursor, height, subsectionsCache); contentLines = result.lines; setSubsectionsCache(result.subsectionsCache); break; } case "Findings": contentLines = renderFindingsView(ctx, cursor, height); break; case "Review Queue": contentLines = renderMemoryQueueView(ctx, cursor, height); break; case "Skills": contentLines = renderSkillsView(ctx, cursor, height); break; case "Hooks": contentLines = renderHooksView(ctx, cursor, height); break; case "Machines/Profiles": contentLines = renderMachinesView(ctx.phrenPath); break; case "Health": { const doctor = await doctorSnapshot(); const result = renderHealthView(ctx.phrenPath, doctor, cursor, height, ctx.currentScroll(), ctx.setScroll); contentLines = result.lines; setHealthLineCount(result.lineCount); break; } case "Graph": { if (!ctx.graph) { contentLines = [" The graph view needs the interactive shell — run `phren shell`."]; break; } const controller = ctx.graph(); await controller.ensureData(); const offer = controller.agentHint(); if (offer) message = ` ${style.boldCyan("▲")} ${style.dim(`${offer} — press`)} ${style.boldCyan("a")} ${style.dim("to see them")}`; contentLines = renderGraphView(controller, renderWidth(), height); // Mouse reports are terminal coordinates; the canvas starts under the top bar. controller.canvasOrigin = { col: 0, row: topBar.split("\n").length }; break; } default: contentLines = [" Unknown view."]; } } const displayed = contentLines.slice(0, height); while (displayed.length < height) displayed.push(""); const cols = renderWidth(); const parts = [topBar, ...displayed, ...(hasMessage ? [` ${style.dimItalic(message.trimStart())}`] : []), bottomBar]; return parts.map(line => { if (line.includes("\n")) { return line.split("\n").map(sub => truncateLine(sub, cols) + "\x1b[K").join("\n"); } return truncateLine(line, cols) + "\x1b[K"; }).join("\n"); }