UNPKG

@phren/cli

Version:

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

497 lines (496 loc) • 22 kB
import { moduleEnabled } from "../modules/runtime.js"; import * as fs from "fs"; import * as path from "path"; import { addTask, addFinding, loadShellState, saveShellState, } from "../data/access.js"; import { style } from "./render.js"; import { MAX_UNDO_STACK, } from "./types.js"; import { logger } from "../logger.js"; import { resultMsg, defaultRunHooks, defaultRunUpdate, defaultRunRelink, } from "./palette.js"; import { runDoctor } from "../link/link.js"; import { renderShell, } from "./view.js"; import { renderWidth } from "./render.js"; import { executePalette, completeInput as completeInputFn, getListItems, handleNavigateKey, applyViewShortcut, } from "./input.js"; import { errorMessage } from "../utils.js"; import { GraphController } from "./graph/controller.js"; import { applyEditorKey, createEditorState, editorText } from "../editor/buffer.js"; import { saveEditedFile } from "../editor/save.js"; import { renderEditor } from "./editor-view.js"; import { syncSkillLinksForScope } from "../skill/files.js"; /** How far the help should scroll for a key, or 0 when it should close instead. */ function helpScrollDelta(key) { switch (key) { case "\x1b[B": case "j": return 1; case "\x1b[A": case "k": return -1; case "\x1b[6~": return 10; case "\x1b[5~": return -10; default: return 0; } } /** Remove the final character, keeping surrogate pairs and combining marks intact. */ function dropLastCharacter(s) { if (!s) return s; const chars = [...s]; chars.pop(); while (chars.length && /\p{M}/u.test(chars[chars.length - 1])) chars.pop(); return chars.join(""); } // ── Shell class ────────────────────────────────────────────────────────────── export class PhrenShell { phrenPath; profile; deps; state; message = ` ${style.boldCyan("←→")} ${style.dim("tabs")} ${style.boldCyan("↑↓")} ${style.dim("move")} ${style.boldCyan("↵")} ${style.dim("activate")} ${style.boldCyan("?")} ${style.dim("help")}`; healthCache; prevHealthView = undefined; showHelp = false; pendingConfirm; undoStack = []; navMode = "navigate"; editor; inputBuf = ""; inputCtx = ""; inputMqId = ""; cursorMap = {}; viewScrollMap = {}; healthLineCount = 0; helpScroll = 0; _subsectionsCache = null; _graph; repaintHandler = null; suspendHandler = null; /** `--live` / `--no-live`; undefined leaves watch mode at its default. */ graphLive; get mode() { return this.navMode; } get editorState() { return this.editor?.state; } get inputBuffer() { return this.inputBuf; } get filter() { return this.state.filter; } constructor(phrenPath, profile, deps = { runDoctor, runRelink: defaultRunRelink, runHooks: defaultRunHooks, runUpdate: defaultRunUpdate, }, startup = {}) { this.phrenPath = phrenPath; this.profile = profile; this.deps = deps; this.state = loadShellState(phrenPath); // A deep link (`phren shell --view tasks --here`) wins over the view the // last session happened to leave behind; without one we always land home. if (startup.project) this.state.project = startup.project; this.graphLive = startup.live; this.state.view = startup.view === "Tasks" && !moduleEnabled(phrenPath, "tasks", profile) ? "Findings" : startup.view ?? "Projects"; this.message = startup.notice ? ` ${style.yellow("⚠")} ${startup.notice}` : this.state.view === "Graph" ? "" : this.state.project ? ` Dashboard ready — active context ${style.boldCyan(this.state.project)}` : ` Dashboard ready — choose a project with ${style.boldCyan("↵")} or stay global`; } close() { this._graph?.dispose(); saveShellState(this.phrenPath, this.state); } setMessage(msg) { this.message = msg; } /** * Let the host (entry.ts) hand over a way to repaint on the shell's own * initiative — the graph view uses it to animate its layout settling and * to show a build that finished while nothing was typed. */ setRepaintHandler(handler) { this.repaintHandler = handler; this._graph?.setRepaintHook(handler); } /** * Let the host lend the terminal to a child process. Without one — a non-TTY * host, or an embedder — callers fall back to telling the user the path. */ /** * The Graph view wants the mouse (drag to orbit, wheel to zoom, click to * select); every other view wants the terminal's own text selection. The * entry point supplies what turning it on and off means for this terminal. */ setMouseHandler(handler) { this.mouseHandler = handler; } mouseHandler = null; /** Apply the mouse state for the current view (after grabbing the terminal). */ syncMouse() { this.mouseHandler?.(this.state.view === "Graph"); } setSuspendHandler(handler) { this.suspendHandler = handler; } get canSuspend() { return this.suspendHandler !== null; } /** Run `fn` with the terminal released. Resolves false when that is impossible. */ async suspend(fn) { if (!this.suspendHandler) return false; await this.suspendHandler(fn); return true; } graph() { if (!this._graph) { this._graph = new GraphController(this.phrenPath, this.profile, { watchEnabled: this.graphLive }); this._graph.setRepaintHook(this.repaintHandler); } return this._graph; } confirmThen(label, action) { this.pendingConfirm = { label, action }; this.setMessage(`${label} ${style.boldCyan("y")} ${style.dim("confirm")} ${style.boldCyan("n")} ${style.dim("cancel")}`); } setView(view) { if (view === "Tasks" && !moduleEnabled(this.phrenPath, "tasks", this.profile)) view = "Findings"; if (this.state.view === "Graph" && view !== "Graph") this._graph?.stopAnimation(); const hadMouse = this.state.view === "Graph"; this.state.view = view; if (hadMouse !== (view === "Graph")) this.mouseHandler?.(view === "Graph"); this.viewScrollMap[view] = 0; saveShellState(this.phrenPath, this.state); } setFilter(value) { this.state.filter = value.trim() || undefined; saveShellState(this.phrenPath, this.state); this.setMessage(this.state.filter ? ` Filter: ${style.yellow(this.state.filter)}` : " Filter cleared."); } snapshotForUndo(label, file) { try { if (fs.existsSync(file)) { const content = fs.readFileSync(file, "utf8"); this.undoStack.push({ label, file, content }); if (this.undoStack.length > MAX_UNDO_STACK) this.undoStack.shift(); } } catch (err) { logger.debug("shell", `shell pushUndo: ${errorMessage(err)}`); } } popUndo() { const entry = this.undoStack.pop(); if (!entry) return "Nothing to undo."; try { fs.writeFileSync(entry.file, entry.content); return `Undid: ${entry.label}`; } catch (err) { return `Undo failed: ${errorMessage(err)}`; } } ensureProjectSelected() { if (!this.state.project) { this.setMessage("No project selected — open one from Projects view (↵) or use :open <project>"); return null; } return this.state.project; } invalidateSubsectionsCache() { this._subsectionsCache = null; } // ── Cursor management ──────────────────────────────────────────────────── currentCursor() { return this.cursorMap[this.state.view] ?? 0; } setCursor(n) { const count = this.getListItems().length; this.cursorMap[this.state.view] = count > 0 ? Math.max(0, Math.min(n, count - 1)) : 0; } moveCursor(delta) { this.setCursor(this.currentCursor() + delta); } currentScroll() { return this.viewScrollMap[this.state.view] ?? 0; } setScroll(n) { this.viewScrollMap[this.state.view] = Math.max(0, n); } getListItems() { return getListItems(this.phrenPath, this.profile, this.state, this.healthLineCount); } startInput(ctx, initial) { this.navMode = "input"; this.inputCtx = ctx; this.inputBuf = initial; } cancelInput() { this.navMode = "navigate"; this.inputBuf = ""; this.inputCtx = ""; this.setMessage(" Cancelled."); } async submitInput() { const buf = this.inputBuf; const ctx = this.inputCtx; this.navMode = "navigate"; this.inputBuf = ""; this.inputCtx = ""; if (!buf.trim() && ctx !== "command" && ctx !== "graph-search") { this.setMessage(" Nothing entered."); return; } switch (ctx) { case "filter": this.setFilter(buf); break; case "graph-search": { const graph = this.graph(); const best = graph.applySearch(buf); if (best) this.setMessage(` ${style.yellow(`1/${graph.search.results.length}`)} ${graph.describe(best).trimStart()}`); else this.setMessage(buf.trim() ? ` ${style.dim("no matches for")} ${style.yellow(buf.trim())}` : ` ${style.dim("search cleared")}`); break; } case "command": await this.runPalette(buf.startsWith(":") ? buf.slice(1) : buf); break; case "add": { const p = this.ensureProjectSelected(); if (!p) return; this.setMessage(` ${resultMsg(addTask(this.phrenPath, p, buf))}`); break; } case "learn-add": { const p = this.ensureProjectSelected(); if (!p) return; this.setMessage(` ${resultMsg(addFinding(this.phrenPath, p, buf))}`); break; } case "skill-add": { const p = this.ensureProjectSelected(); if (!p) return; const name = buf.trim().replace(/\.md$/i, "").replace(/[^a-zA-Z0-9_-]/g, "-"); if (!name) { this.setMessage(" No name entered."); return; } const destDir = path.join(this.phrenPath, p, "skills"); fs.mkdirSync(destDir, { recursive: true }); const dest = path.join(destDir, `${name}.md`); if (fs.existsSync(dest)) { this.setMessage(` Skill "${name}" already exists.`); return; } const template = `# ${name}\n\nDescribe what this skill does.\n\n## Usage\n\n\`\`\`\nExample usage here\n\`\`\`\n`; fs.writeFileSync(dest, template, "utf8"); this.setMessage(` Created skill "${name}" — edit ${dest}`); break; } } } // ── Modal editor ─────────────────────────────────────────────────────── /** Open a file in the built-in editor. Returns false if it cannot be read. */ openEditor(filePath, label, kind, scope) { let content = ""; try { content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; } catch (err) { this.setMessage(` Could not open ${label}: ${errorMessage(err)}`); return false; } this.editor = { state: createEditorState(filePath, label, content), kind, scope }; this.navMode = "editor"; return true; } closeEditor(message) { this.editor = undefined; this.navMode = "navigate"; this.invalidateSubsectionsCache(); this.setMessage(message); } saveEditor() { const open = this.editor; if (!open) return false; // The same undo stack :undo uses, so a bad save is recoverable. this.snapshotForUndo(`edit ${open.state.label}`, open.state.path); const result = saveEditedFile(open.state.path, editorText(open.state), open.kind); if (!result.ok) { open.state = { ...open.state, message: result.error ?? "could not save" }; return false; } // Only a frontmatter change moves what the manifests contain. if (result.frontmatterChanged && open.scope) { try { syncSkillLinksForScope(this.phrenPath, open.scope); } catch (err) { logger.debug("shell", `skill re-sync: ${errorMessage(err)}`); } } open.state = { ...open.state, dirty: false, message: `written ${open.state.label}` }; return true; } async handleEditorKey(key) { const open = this.editor; if (!open) { this.navMode = "navigate"; return true; } open.state = applyEditorKey(open.state, key); if (open.state.wantSave) { open.state = { ...open.state, wantSave: false }; const saved = this.saveEditor(); // A failed save cancels a :wq — you do not want to lose the buffer. if (!saved && this.editor) this.editor.state = { ...this.editor.state, wantClose: false }; } if (this.editor?.state.wantClose) { const label = this.editor.state.label; const wasDirty = this.editor.state.dirty; this.closeEditor(wasDirty ? ` Closed ${label} without saving` : ` ${style.green("✓")} ${label}`); } return true; } // ── Raw key handling ─────────────────────────────────────────────────── async handleRawKey(key) { if (key === "\x03" || key === "\x04") return false; if (this.pendingConfirm) { const pending = this.pendingConfirm; this.pendingConfirm = undefined; if (key.toLowerCase() === "y") { pending.action(); } else { this.setMessage(" Cancelled."); } return true; } if (this.showHelp) { // Scroll rather than dismiss, so the half of the help that does not fit // on a small terminal is reachable at all. const scroll = helpScrollDelta(key); if (scroll !== 0) { this.helpScroll = Math.max(0, this.helpScroll + scroll); return true; } this.showHelp = false; this.helpScroll = 0; this.setMessage(` ${style.boldCyan("←→")} ${style.dim("tabs")} ${style.boldCyan("↑↓")} ${style.dim("move")} ${style.boldCyan("↵")} ${style.dim("activate")} ${style.boldCyan("?")} ${style.dim("help")}`); return true; } if (this.navMode === "editor") return this.handleEditorKey(key); return this.navMode === "input" ? this.handleInputKey(key) : handleNavigateKey(this.asNavigationHost(), key); } async handleInputKey(key) { if (key === "\x03") return false; if (key === "\x1b") { this.cancelInput(); return true; } if (key === "\r" || key === "\n") { await this.submitInput(); return true; } if (key === "\x7f" || key === "\x08") { this.inputBuf = dropLastCharacter(this.inputBuf); return true; } if (key.startsWith("\x1b")) return true; // Accept any printable key. Astral characters (emoji, and anything else the // user pastes) span two UTF-16 units, so this cannot test key.length === 1. const cp = key.codePointAt(0); if (cp !== undefined && cp >= 32 && cp !== 0x7f) { this.inputBuf += key; return true; } return true; } // ── Doctor snapshot ──────────────────────────────────────────────────── async doctorSnapshot() { if (this.healthCache && Date.now() - this.healthCache.at < 10_000) return this.healthCache.result; const result = await this.deps.runDoctor(this.phrenPath, false); this.healthCache = { at: Date.now(), result }; return result; } // ── Render (delegates to shell-view.ts) ──────────────────────────────── async render() { // The editor takes the whole frame: it is a mode, not a view. if (this.navMode === "editor" && this.editor) { const rows = Math.max(4, process.stdout.rows || 24); const lines = renderEditor(this.editor.state, renderWidth(), rows); return lines.map((line) => `${line}\x1b[K`).join("\n"); } const ctx = { phrenPath: this.phrenPath, profile: this.profile, state: this.state, currentCursor: () => this.currentCursor(), currentScroll: () => this.currentScroll(), setScroll: (n) => this.setScroll(n), graph: () => this.graph(), }; // The editor path returned above, so this is only ever navigate or input. const viewMode = this.navMode === "input" ? "input" : "navigate"; return renderShell(ctx, viewMode, this.inputCtx, this.inputBuf, this.showHelp, this.helpScroll, this.message, () => this.doctorSnapshot(), this._subsectionsCache, (n) => { this.healthLineCount = n; }, (c) => { this._subsectionsCache = c; }); } // ── Navigation host adapter ──────────────────────────────────────────── asNavigationHost() { const self = this; return { phrenPath: this.phrenPath, profile: this.profile, state: this.state, deps: this.deps, get showHelp() { return self.showHelp; }, set showHelp(v) { self.showHelp = v; }, get healthCache() { return self.healthCache; }, set healthCache(v) { self.healthCache = v; }, get prevHealthView() { return self.prevHealthView; }, set prevHealthView(v) { self.prevHealthView = v; }, get filter() { return self.state.filter; }, get inputMqId() { return self.inputMqId; }, set inputMqId(v) { self.inputMqId = v; }, setMessage: (msg) => this.setMessage(msg), setView: (view) => this.setView(view), setFilter: (value) => this.setFilter(value), confirmThen: (label, action) => this.confirmThen(label, action), snapshotForUndo: (label, file) => this.snapshotForUndo(label, file), ensureProjectSelected: () => this.ensureProjectSelected(), invalidateSubsectionsCache: () => this.invalidateSubsectionsCache(), popUndo: () => this.popUndo(), currentCursor: () => this.currentCursor(), setCursor: (n) => this.setCursor(n), moveCursor: (delta) => this.moveCursor(delta), getListItems: () => this.getListItems(), startInput: (ctx, initial) => this.startInput(ctx, initial), graph: () => this.graph(), suspend: (fn) => this.suspend(fn), openEditor: (filePath, label, kind, scope) => this.openEditor(filePath, label, kind, scope), }; } // ── Palette (delegates to shell-input.ts) ────────────────────────────── async runPalette(input) { await executePalette(this.asNavigationHost(), input); } async handleInput(raw) { const input = raw.trim(); if (this.pendingConfirm) { const pending = this.pendingConfirm; this.pendingConfirm = undefined; if (input.toLowerCase() === "y") { pending.action(); } else { this.setMessage(" Cancelled."); } return true; } if (this.showHelp) { this.showHelp = false; this.helpScroll = 0; this.setMessage(` ${style.boldCyan("←→")} ${style.dim("tabs")} ${style.boldCyan("↑↓")} ${style.dim("move")} ${style.boldCyan("↵")} ${style.dim("activate")} ${style.boldCyan("?")} ${style.dim("help")}`); if (!input) return true; } if (!input) return true; if (["q", "quit", ":q", ":quit", ":exit"].includes(input.toLowerCase())) return false; if (applyViewShortcut(this.asNavigationHost(), input)) return true; if (input.startsWith("/")) { this.setFilter(input.slice(1)); return true; } if (input.startsWith(":")) { await this.runPalette(input.slice(1)); return true; } await this.runPalette(input); return true; } completeInput(line) { return completeInputFn(line, this.phrenPath, this.profile, this.state); } } export { startShell } from "./entry.js";