UNPKG

@phren/cli

Version:

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

324 lines (323 loc) • 12.7 kB
/** * Shell entry point: wires PhrenShell to stdin/stdout. * Extracted from shell.ts to keep the orchestrator under 300 lines. */ import { PhrenShell } from "./shell.js"; import { MOUSE_OFF, MOUSE_ON } from "./graph/orbit.js"; import { style, clearScreen, paintFrame, enterFullscreen, exitFullscreen, } from "./render.js"; import { KeyDecoder, ESC_FLUSH_MS } from "./keys.js"; import { playSplash } from "./intro.js"; import { errorMessage } from "../utils.js"; import { computePhrenLiveStateToken } from "../shared.js"; import { VERSION } from "../init/shared.js"; import { loadShellState, saveShellState } from "./state-store.js"; const LIVE_STATE_POLL_MS = 2000; const noopWaiter = () => Promise.resolve(); export function resolveStartupIntroPlan(phrenPath, version = VERSION) { const state = loadShellState(phrenPath); const mode = state.introMode === "always" || state.introMode === "off" ? state.introMode : "once-per-version"; if (mode === "off") { return { mode, variant: "skip", holdForKeypress: false, dwellMs: 0, markSeen: false }; } if (mode === "always") { return { mode, variant: "full", holdForKeypress: false, dwellMs: 700, markSeen: true }; } if (state.introSeenVersion !== version) { return { mode, variant: "full", holdForKeypress: true, dwellMs: 0, markSeen: true }; } return { mode, variant: "final-frame", holdForKeypress: false, dwellMs: 550, markSeen: false }; } function markStartupIntroSeen(phrenPath, version = VERSION) { const state = loadShellState(phrenPath); if (state.introSeenVersion === version) return; saveShellState(phrenPath, { ...state, introSeenVersion: version }); } async function playStartupIntro(phrenPath, plan = resolveStartupIntroPlan(phrenPath), waitForAnyKeypress = noopWaiter) { if (!process.stdout.isTTY || plan.variant === "skip") return; // The shell is already on the alternate screen; the splash paints in place. // A full intro plays the wordmark reveal, a repeat launch opens on the // finished wordmark, and either way the shimmer runs until dismissed. await playSplash({ version: VERSION, hint: plan.holdForKeypress ? "Press any key to enter" : "Loading shell…", reveal: plan.variant === "full", dwellMs: plan.dwellMs, waitForKeypress: plan.holdForKeypress ? waitForAnyKeypress : undefined, fullscreen: false, }); if (plan.markSeen) { markStartupIntroSeen(phrenPath); } } export function startLiveStatePoller({ phrenPath, shell, repaint, isExiting = () => false, intervalMs = LIVE_STATE_POLL_MS, computeToken = computePhrenLiveStateToken, }) { let liveStateToken = computeToken(phrenPath); let stopped = false; let inFlight = false; const pollOnce = async () => { if (stopped || inFlight || isExiting()) return; inFlight = true; try { const nextToken = computeToken(phrenPath); if (nextToken === liveStateToken) return; liveStateToken = nextToken; shell.invalidateSubsectionsCache(); shell.setMessage(` ${style.boldCyan("Live")} ${style.dim("store updated")}`); await repaint(); } finally { inFlight = false; } }; const poll = setInterval(() => { void pollOnce(); }, intervalMs); poll.unref?.(); return () => { stopped = true; clearInterval(poll); }; } export async function startShell(phrenPath, profile, startup = {}) { const shell = new PhrenShell(phrenPath, profile, undefined, startup); if (!process.stdin.isTTY) { const { createInterface } = await import("readline"); const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY ?? false }); const repaint = async () => { clearScreen(); process.stdout.write(await shell.render()); rl.setPrompt(`\n${style.boldCyan(":phren>")} `); rl.prompt(); }; const stopPoll = startLiveStatePoller({ phrenPath, shell, repaint }); await repaint(); rl.on("line", async (line) => { try { const keep = await shell.handleInput(line); if (!keep) { shell.close(); rl.close(); return; } } catch (err) { process.stdout.write(`\n${style.red("Error:")} ${String(errorMessage(err))}\n`); } await repaint(); }); rl.on("SIGINT", () => { stopPoll(); shell.close(); rl.close(); }); rl.on("close", () => { stopPoll(); }); await new Promise((resolve) => { rl.on("close", () => { shell.close(); resolve(); }); }); return; } // Taking and releasing the terminal is written once and used twice: at // startup and shutdown, and again either side of handing the terminal to an // editor. Two copies of this would drift. const grabTerminal = () => { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); enterFullscreen(); }; grabTerminal(); let exiting = false; let cleanedUp = false; // ── Painting ──────────────────────────────────────────────────────────── // render() is async (the Health view awaits a doctor snapshot) and repaints // are triggered from three independent sources — keys, resize, and the live // store poller — none of which await each other. Serialising them keeps two // renders from interleaving their writes; a repaint requested mid-render is // coalesced into a single trailing repaint instead of queueing up. let painting = false; let repaintQueued = false; const repaint = async () => { if (painting) { repaintQueued = true; return; } painting = true; try { do { repaintQueued = false; const frame = await shell.render(); if (exiting) return; paintFrame(frame); } while (repaintQueued); } finally { painting = false; } }; // The graph view animates (layout settle, fly-to) and finishes builds off // the key path; give the shell a way to repaint without a keypress. shell.setRepaintHandler(() => { if (!exiting && !introActive) void repaint(); }); let done; const exitPromise = new Promise((resolve) => { done = resolve; }); const restoreTerminal = () => { // Shutdown cleanup is intentionally silent: terminal restoration is best-effort // cleanup, not a user-requested write path. try { process.stdout.write(MOUSE_OFF); } catch { } try { process.stdin.setRawMode(false); } catch { } try { process.stdin.pause(); } catch { } try { exitFullscreen(); } catch { } }; // ── Key delivery ──────────────────────────────────────────────────────── // stdin hands us whatever arrived in one read, which is often several keys // (autorepeat, fast typing, paste). Decode the chunk into discrete keys, then // drain them through one sequential pump so handlers never run concurrently, // and repaint once per burst rather than once per key. const decoder = new KeyDecoder(); const keyQueue = []; let escTimer; let introKeyWaiter; // Keys pressed while the splash is on screen queue up rather than repainting // the dashboard underneath a frame the intro is still animating over. let introActive = true; const dispatch = (keys) => { if (!keys.length) return; if (introKeyWaiter) { // The splash consumes the key that dismisses it; the rest still count. const resolveIntro = introKeyWaiter; introKeyWaiter = undefined; keys = keys.slice(1); resolveIntro(); } keyQueue.push(...keys); void pump(); }; let pumping = false; const pump = async () => { if (pumping || introActive) return; pumping = true; try { while (!exiting && keyQueue.length) { while (!exiting && keyQueue.length) { const key = keyQueue.shift(); try { const keep = await shell.handleRawKey(key); if (!keep) { exiting = true; keyQueue.length = 0; finish(); return; } } catch (err) { shell.setMessage(`Error: ${errorMessage(err)}`); } } if (!exiting) await repaint(); } } finally { pumping = false; } }; const onData = (chunk) => { if (exiting) return; if (escTimer) { clearTimeout(escTimer); escTimer = undefined; } dispatch(decoder.push(chunk)); if (decoder.hasPending()) { // A trailing ESC is either the Escape key or the head of a sequence split // across reads. Wait briefly for the rest before treating it as Escape. escTimer = setTimeout(() => { escTimer = undefined; dispatch(decoder.flush()); }, ESC_FLUSH_MS); escTimer.unref?.(); } }; const waitForIntroKeypress = () => new Promise((resolve) => { introKeyWaiter = resolve; }); const onResize = async () => { if (!exiting) await repaint(); }; const onSignal = () => { if (exiting) return; exiting = true; finish(); }; const onProcessExit = () => { restoreTerminal(); }; const stopPoll = startLiveStatePoller({ phrenPath, shell, repaint, isExiting: () => exiting }); const finish = () => { if (cleanedUp) return; cleanedUp = true; stopPoll(); if (escTimer) { clearTimeout(escTimer); escTimer = undefined; } introKeyWaiter?.(); introKeyWaiter = undefined; process.stdin.removeListener("data", onData); process.stdout.removeListener("resize", onResize); process.removeListener("SIGINT", onSignal); process.removeListener("SIGTERM", onSignal); process.removeListener("exit", onProcessExit); restoreTerminal(); shell.close(); done(); }; process.stdin.on("data", onData); process.stdout.on("resize", onResize); process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal); process.once("exit", onProcessExit); // Hand the terminal to a child process (an editor) and take it back. The // shell owns raw mode, the alternate screen and the stdin listener, so all // three have to be unwound or the child and the key pump fight over input. // Mouse reporting is switched on only while the Graph view is showing; // elsewhere the terminal keeps its own click-and-drag text selection. shell.setMouseHandler((on) => { try { process.stdout.write(on ? MOUSE_ON : MOUSE_OFF); } catch { } }); shell.syncMouse(); shell.setSuspendHandler(async (run) => { if (exiting) return; process.stdin.removeListener("data", onData); restoreTerminal(); try { await run(); } finally { if (!exiting) { grabTerminal(); shell.syncMouse(); process.stdin.on("data", onData); // Anything typed at the child after it exited is not for us. decoder.flush(); await repaint(); } } }); try { await playStartupIntro(phrenPath, resolveStartupIntroPlan(phrenPath), waitForIntroKeypress); introKeyWaiter = undefined; introActive = false; if (!exiting) await repaint(); void pump(); await exitPromise; } finally { finish(); } }