@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
1,079 lines (1,078 loc) • 44.5 kB
JavaScript
import { filterTaskDoc } from "../data/task-contract.js";
import { nonInteractiveGitEnv } from "../utils-helpers.js";
/**
* Command palette and input handling for the phren interactive shell.
* Extracted from shell.ts to keep the orchestrator under 300 lines.
*/
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import { addTask, addFinding, addProjectToProfile, completeTask, listProjectCards, pinTask, readTasks, readFindings, readReviewQueue, removeFinding, removeProjectFromProfile, resetShellState, saveShellState, setMachineProfile, tidyDoneTasks, canonicalTaskFilePath, unpinTask, updateTask, workNextTask, loadShellState, resolveTaskFilePath, FINDINGS_FILENAME, } from "../data/access.js";
import { runtimeFile } from "../shared.js";
import { handleGovernMemories } from "../cli/govern.js";
import { runSearch } from "../cli/search.js";
import { consolidateProjectFindings } from "../governance/policy.js";
import { style } from "./render.js";
import { enabledSubViews, TAB_ICONS } from "./types.js";
import { getProjectSkills, getHookEntries, writeInstallPreferences } from "./view.js";
import { resolveProjectStorePath } from "../cli/namespaces-utils.js";
import { openInEditor } from "../editor/launch.js";
import { removeSkillPath, setSkillEnabledAndSync } from "../skill/files.js";
import { resultMsg, editDistance, splitCommandLine, expandIds, normalizeSection, tasksByFilter, queueByFilter, } from "./palette.js";
import { errorMessage } from "../utils.js";
import { logger } from "../logger.js";
/**
* The file `e` and `E` edit for the current view. Skills edit their own
* markdown; a project edits the AGENTS.md the store owns and symlinks into the
* repo, so editing here reaches every linked checkout.
*/
export function editTargetFor(host, item) {
if (host.state.view === "Skills") {
if (!item?.path)
return null;
return { path: item.path, label: item.name ?? path.basename(item.path), kind: "skill" };
}
if (host.state.view === "Projects") {
const project = item?.name ?? host.state.project;
if (!project)
return null;
const store = resolveProjectStorePath(host.phrenPath, project);
return { path: path.join(store, project, "AGENTS.md"), label: `${project}/AGENTS.md`, kind: "claude" };
}
return null;
}
function taskFileForProject(phrenPath, project) {
return resolveTaskFilePath(phrenPath, project)
?? canonicalTaskFilePath(phrenPath, project)
?? path.join(phrenPath, project, "tasks.md");
}
export async function executePalette(host, input) {
const trimmed = input.trim();
if (!trimmed)
return;
const parts = splitCommandLine(trimmed);
const command = (parts[0] || "").toLowerCase();
if (command === "help") {
host.showHelp = true;
host.setMessage(" Showing help — ↑↓ to scroll, any other key to dismiss");
return;
}
if (command === "projects") {
host.setView("Projects");
host.setMessage(` ${TAB_ICONS.Projects} Projects`);
return;
}
if (command === "tasks" || command === "task") {
host.setView("Tasks");
host.setMessage(` ${TAB_ICONS.Tasks} Tasks`);
return;
}
if (command === "learnings" || command === "findings" || command === "fragments") {
host.setView("Findings");
host.setMessage(` ${TAB_ICONS.Findings} Fragments`);
return;
}
if (command === "memory") {
host.setView("Review Queue");
host.setMessage(` ${TAB_ICONS["Review Queue"]} Review Queue`);
return;
}
if (command === "machines") {
host.setView("Machines/Profiles");
host.setMessage(" Machines/Profiles");
return;
}
if (command === "health") {
host.healthCache = undefined;
host.setView("Health");
host.setMessage(` ${TAB_ICONS.Health} Health`);
return;
}
if (command === "graph" || command === "map") {
host.setView("Graph");
host.setMessage(` ${TAB_ICONS.Graph} Graph`);
return;
}
if (command === "open") {
const project = parts[1];
if (!project) {
host.setMessage(" Usage: :open <project>");
return;
}
const cards = listProjectCards(host.phrenPath, host.profile);
if (!cards.some((c) => c.name === project)) {
host.setMessage(` Unknown project: ${project}`);
return;
}
host.state.project = project;
saveShellState(host.phrenPath, host.state);
host.setMessage(` ${style.green("●")} ${style.boldCyan(project)} — project context set`);
return;
}
if (command === "search") {
const query = trimmed.slice("search".length).trim();
if (!query) {
host.setMessage(" Usage: :search <query>");
return;
}
host.setMessage(" Searching…");
try {
const result = await runSearch({
query,
limit: 6,
project: host.state.project,
}, host.phrenPath, host.profile);
host.setMessage(result.lines.slice(0, 14).join("\n") || " No results.");
}
catch (err) {
host.setMessage(` Search failed: ${errorMessage(err)}`);
}
return;
}
if (command === "intro") {
const modeRaw = (parts[1] || "").toLowerCase();
const mode = modeRaw === "always" || modeRaw === "off" ? modeRaw : modeRaw === "once" ? "once-per-version" : modeRaw;
if (!["always", "once-per-version", "off"].includes(mode)) {
host.setMessage(" Usage: :intro always|once-per-version|off");
return;
}
host.state.introMode = mode;
saveShellState(host.phrenPath, host.state);
host.setMessage(` Intro mode: ${style.boldCyan(mode)}`);
return;
}
if (command === "add") {
const project = host.ensureProjectSelected();
if (!project)
return;
const text = trimmed.slice("add".length).trim();
if (!text) {
host.setMessage(" Usage: :add <task>");
return;
}
host.setMessage(` ${resultMsg(addTask(host.phrenPath, project, text))}`);
return;
}
if (command === "lane") {
const value = (parts[1] ?? "").toLowerCase();
if (!["human", "agent", "all"].includes(value)) {
host.setMessage(" Usage: :lane human|agent|all");
return;
}
host.state.taskResponsibility = value === "all" ? undefined : value;
saveShellState(host.phrenPath, host.state);
host.setView("Tasks");
host.setMessage(` Task responsibility: ${value}`);
return;
}
if (command === "responsibility" || command === "depends") {
const project = host.ensureProjectSelected();
if (!project)
return;
const match = parts[1];
if (!match) {
host.setMessage(" Usage: :responsibility <id> human|agent or :depends <id> <JSON array>");
return;
}
const value = parts.slice(2).join(" ");
let updates;
if (command === "responsibility") {
if (value !== "human" && value !== "agent") {
host.setMessage(" Responsibility must be human or agent.");
return;
}
updates = { responsibility: value };
}
else {
try {
updates = { dependencies: JSON.parse(value) };
}
catch {
host.setMessage(" Dependencies must be a JSON array.");
return;
}
}
host.snapshotForUndo(command, taskFileForProject(host.phrenPath, project));
host.setMessage(` ${resultMsg(updateTask(host.phrenPath, project, match, updates))}`);
return;
}
if (command === "complete") {
const project = host.ensureProjectSelected();
if (!project)
return;
const match = parts.slice(1).join(" ").trim();
if (!match) {
host.setMessage(" Usage: :complete <id|match>");
return;
}
const ids = expandIds(match);
if (ids.length > 1) {
host.confirmThen(`Complete ${ids.length} items (${ids.join(", ")})?`, () => {
const file = taskFileForProject(host.phrenPath, project);
host.snapshotForUndo(`complete ${ids.length} items`, file);
host.setMessage(ids.map((id) => resultMsg(completeTask(host.phrenPath, project, id))).join("; "));
});
}
else {
host.confirmThen(`Complete "${match}"?`, () => {
const file = taskFileForProject(host.phrenPath, project);
host.snapshotForUndo(`complete "${match}"`, file);
host.setMessage(` ${resultMsg(completeTask(host.phrenPath, project, match))}`);
});
}
return;
}
if (command === "move") {
const project = host.ensureProjectSelected();
if (!project)
return;
if (parts.length < 3) {
host.setMessage(" Usage: :move <id|match> <active|queue|done>");
return;
}
const section = normalizeSection(parts[parts.length - 1]);
if (!section) {
host.setMessage(" Target section must be active|queue|done");
return;
}
const match = parts.slice(1, -1).join(" ");
const ids = expandIds(match);
if (ids.length > 1) {
const file = taskFileForProject(host.phrenPath, project);
host.snapshotForUndo(`move ${ids.length} items to ${section}`, file);
host.setMessage(ids.map((id) => resultMsg(updateTask(host.phrenPath, project, id, { section }))).join("; "));
}
else {
host.setMessage(` ${resultMsg(updateTask(host.phrenPath, project, match, { section }))}`);
}
return;
}
if (command === "reprioritize") {
const project = host.ensureProjectSelected();
if (!project)
return;
if (parts.length < 3) {
host.setMessage(" Usage: :reprioritize <id|match> <high|medium|low>");
return;
}
const priorityRaw = parts[parts.length - 1].toLowerCase();
if (!["high", "medium", "low"].includes(priorityRaw)) {
host.setMessage(" Priority must be high|medium|low");
return;
}
const priority = priorityRaw;
const match = parts.slice(1, -1).join(" ");
host.setMessage(` ${resultMsg(updateTask(host.phrenPath, project, match, { priority }))}`);
return;
}
if (command === "context") {
const project = host.ensureProjectSelected();
if (!project)
return;
if (parts.length < 3) {
host.setMessage(" Usage: :context <id|match> <text>");
return;
}
const match = parts[1];
const context = parts.slice(2).join(" ");
host.setMessage(` ${resultMsg(updateTask(host.phrenPath, project, match, { context }))}`);
return;
}
if (command === "pin") {
const project = host.ensureProjectSelected();
if (!project)
return;
if (parts.length < 2) {
host.setMessage(" Usage: :pin <id|match>");
return;
}
host.setMessage(` ${resultMsg(pinTask(host.phrenPath, project, parts.slice(1).join(" ")))}`);
return;
}
if (command === "unpin") {
const project = host.ensureProjectSelected();
if (!project)
return;
if (parts.length < 2) {
host.setMessage(" Usage: :unpin <id|match>");
return;
}
host.setMessage(` ${resultMsg(unpinTask(host.phrenPath, project, parts.slice(1).join(" ")))}`);
return;
}
if (command === "work" && parts[1]?.toLowerCase() === "next") {
const project = host.ensureProjectSelected();
if (!project)
return;
host.setMessage(` ${resultMsg(workNextTask(host.phrenPath, project))}`);
return;
}
if (command === "tidy") {
const project = host.ensureProjectSelected();
if (!project)
return;
const keep = parts[1] ? Number.parseInt(parts[1], 10) : 30;
const file = taskFileForProject(host.phrenPath, project);
host.snapshotForUndo("tidy", file);
host.setMessage(` ${resultMsg(tidyDoneTasks(host.phrenPath, project, Number.isNaN(keep) ? 30 : keep))}`);
return;
}
if (command === "learn" || command === "find") {
const project = host.ensureProjectSelected();
if (!project)
return;
const action = (parts[1] || "").toLowerCase();
if (action === "add") {
const text = trimmed.split(/\s+/).slice(2).join(" ").trim();
if (!text) {
host.setMessage(" Usage: :find add <text>");
return;
}
host.setMessage(` ${resultMsg(addFinding(host.phrenPath, project, text))}`);
return;
}
if (action === "remove") {
const match = parts.slice(2).join(" ").trim();
if (!match) {
host.setMessage(" Usage: :find remove <id|match>");
return;
}
host.confirmThen(`Remove finding "${match}"?`, () => {
const file = path.join(host.phrenPath, project, FINDINGS_FILENAME);
host.snapshotForUndo(`find remove "${match}"`, file);
host.setMessage(` ${resultMsg(removeFinding(host.phrenPath, project, match))}`);
});
return;
}
host.setMessage(" Usage: :find add <text> | :find remove <id|match>");
return;
}
if (command === "machine" && parts[1]?.toLowerCase() === "map") {
if (parts.length < 4) {
host.setMessage(" Usage: :machine map <hostname> <profile>");
return;
}
host.setMessage(` ${resultMsg(setMachineProfile(host.phrenPath, parts[2], parts[3]))}`);
return;
}
if (command === "profile") {
const action = (parts[1] || "").toLowerCase();
const profileName = parts[2];
const project = parts[3];
if (!profileName || !project) {
host.setMessage(" Usage: :profile add-project|remove-project <profile> <project>");
return;
}
if (action === "add-project") {
host.setMessage(` ${resultMsg(addProjectToProfile(host.phrenPath, profileName, project))}`);
return;
}
if (action === "remove-project") {
host.setMessage(` ${resultMsg(removeProjectFromProfile(host.phrenPath, profileName, project))}`);
return;
}
host.setMessage(" Usage: :profile add-project|remove-project <profile> <project>");
return;
}
if ((command === "run" && parts[1]?.toLowerCase() === "fix") || command === "doctor") {
const t0 = Date.now();
const doctor = await host.deps.runDoctor(host.phrenPath, true);
host.healthCache = undefined;
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
host.setMessage(` doctor --fix: ${doctor.ok ? style.green("ok") : style.red("issues remain")} (${elapsed}s)`);
return;
}
if (command === "relink") {
const t0 = Date.now();
const r = await host.deps.runRelink(host.phrenPath);
host.setMessage(` ${r} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
return;
}
if (command === "rerun" && parts[1]?.toLowerCase() === "hooks") {
const t0 = Date.now();
const r = await host.deps.runHooks(host.phrenPath);
host.healthCache = undefined;
host.setMessage(` ${r} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
return;
}
if (command === "update") {
const t0 = Date.now();
const r = await host.deps.runUpdate();
host.setMessage(` ${r} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
return;
}
if (command === "govern") {
const project = host.ensureProjectSelected();
if (!project)
return;
try {
const t0 = Date.now();
const summary = await handleGovernMemories(project, true);
host.setMessage(` Governed memories: stale=${summary.staleCount}, conflicts=${summary.conflictCount}, review=${summary.reviewCount}` +
` (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
}
catch (err) {
host.setMessage(` Governance failed: ${errorMessage(err)}`);
}
return;
}
if (command === "consolidate") {
const project = host.ensureProjectSelected();
if (!project)
return;
try {
const t0 = Date.now();
const backupPath = path.join(host.phrenPath, project, "FINDINGS.md.bak");
const backupBefore = fs.existsSync(backupPath) ? fs.statSync(backupPath).mtimeMs : undefined;
const result = consolidateProjectFindings(host.phrenPath, project);
const backupAfter = fs.existsSync(backupPath) ? fs.statSync(backupPath).mtimeMs : undefined;
const backupNote = result.ok && backupAfter !== undefined && backupAfter !== backupBefore
? `; Updated backup: ${path.relative(host.phrenPath, backupPath).replace(/\\/g, "/")}`
: "";
host.setMessage(` ${resultMsg(result)}${backupNote} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
}
catch (err) {
host.setMessage(` Consolidation failed: ${errorMessage(err)}`);
}
return;
}
if (command === "conflicts") {
try {
const lines = [];
try {
const conflicted = execFileSync("git", ["diff", "--name-only", "--diff-filter=U"], {
env: nonInteractiveGitEnv(),
cwd: host.phrenPath, encoding: "utf8", timeout: 10_000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (conflicted) {
lines.push(style.boldRed(" Unresolved conflicts:"));
for (const f of conflicted.split("\n").filter(Boolean)) {
lines.push(` ${style.red("!")} ${f}`);
}
}
}
catch (err) {
logger.debug("shell-input", `shell status gitStatus: ${errorMessage(err)}`);
}
const auditPathNew = runtimeFile(host.phrenPath, "audit.log");
const auditPathLegacy = path.join(host.phrenPath, ".config", "audit.log");
const auditPath = fs.existsSync(auditPathNew) ? auditPathNew : auditPathLegacy;
if (fs.existsSync(auditPath)) {
const auditLines = fs.readFileSync(auditPath, "utf8").split("\n")
.filter((l) => l.includes("auto_merge"))
.slice(-10);
if (auditLines.length) {
lines.push(` ${style.bold("Recent auto-merges:")}`);
for (const l of auditLines)
lines.push(` ${style.dim(l)}`);
}
}
const project = host.state.project;
if (project) {
const queueResult = readReviewQueue(host.phrenPath, project);
if (queueResult.ok) {
const conflictItems = queueResult.data.filter((q) => q.section === "Conflicts");
if (conflictItems.length) {
lines.push(` ${style.yellow(`${conflictItems.length} conflict(s) in Review Queue`)} (inspect in :review queue)`);
}
}
}
host.setMessage(lines.length ? lines.join("\n") : " No conflicts found.");
}
catch (err) {
host.setMessage(` Conflict check failed: ${errorMessage(err)}`);
}
return;
}
if (command === "undo") {
host.setMessage(` ${host.popUndo()}`);
return;
}
if (command === "diff") {
const project = host.ensureProjectSelected();
if (!project)
return;
try {
const projectDir = path.join(host.phrenPath, project);
const diff = execFileSync("git", ["diff", "--no-color", "--", projectDir], {
env: nonInteractiveGitEnv(),
cwd: host.phrenPath, encoding: "utf8", timeout: 10_000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (!diff) {
const staged = execFileSync("git", ["diff", "--cached", "--no-color", "--", projectDir], {
env: nonInteractiveGitEnv(),
cwd: host.phrenPath, encoding: "utf8", timeout: 10_000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
host.setMessage(staged || " No uncommitted changes.");
}
else {
const lines = diff.split("\n").slice(0, 30);
if (diff.split("\n").length > 30)
lines.push(style.dim(`... (${diff.split("\n").length - 30} more lines)`));
host.setMessage(lines.join("\n"));
}
}
catch {
host.setMessage(" Not a git repository or git not available.");
}
return;
}
if (command === "reset") {
host.setMessage(` ${resultMsg(resetShellState(host.phrenPath))}`);
const newState = loadShellState(host.phrenPath);
Object.assign(host.state, newState);
const cards = listProjectCards(host.phrenPath, host.profile);
host.state.project = cards[0]?.name;
return;
}
const suggestion = suggestCommand(command);
if (suggestion) {
host.setMessage(` Unknown: ${trimmed} — did you mean :${suggestion}?`);
}
else {
host.setMessage(` Unknown: ${trimmed} — press ${style.boldCyan("?")} for help`);
}
}
function suggestCommand(input) {
const known = [
"help", "projects", "tasks", "task", "findings", "review queue", "machines", "health", "graph",
"open", "search", "add", "complete", "move", "reprioritize", "pin", "unpin", "context",
"work next", "tidy", "find add", "find remove", "machine map", "profile add-project", "profile remove-project",
"run fix", "relink", "rerun hooks", "update", "govern", "consolidate",
"undo", "diff", "conflicts", "reset",
];
let best;
let bestDist = Infinity;
for (const cmd of known) {
const d = editDistance(input.toLowerCase(), cmd);
if (d < bestDist && d <= 2) {
bestDist = d;
best = cmd;
}
}
return best;
}
export function completeInput(line, phrenPath, profile, state) {
const commands = [
":projects", ":tasks", ":task", ":lane", ":responsibility", ":depends", ":findings", ":review queue", ":machines", ":health", ":graph",
":open", ":search", ":add", ":complete", ":move", ":reprioritize", ":pin",
":unpin", ":context", ":work next", ":tidy", ":find add", ":find remove",
":machine map",
":profile add-project", ":profile remove-project",
":run fix", ":relink", ":rerun hooks", ":update", ":govern", ":consolidate",
":undo", ":diff", ":conflicts", ":reset", ":help",
];
const trimmed = line.trimStart();
if (!trimmed.startsWith(":"))
return [];
const after = trimmed.slice(1);
const parts = splitCommandLine(after);
const endsWithSpace = /\s$/.test(trimmed);
if (parts.length === 0)
return commands;
if (parts.length === 1 && !endsWithSpace) {
const prefix = `:${parts[0].toLowerCase()}`;
return commands.filter((c) => c.startsWith(prefix));
}
const cmd = parts[0].toLowerCase();
if (cmd === "open") {
return listProjectCards(phrenPath, profile).map((c) => `:open ${c.name}`);
}
if (["complete", "move", "reprioritize", "context", "pin", "unpin", "responsibility", "depends"].includes(cmd)) {
const project = state.project;
if (!project)
return [];
const result = readTasks(phrenPath, project);
if (!result.ok)
return [];
return [
...result.data.items.Active,
...result.data.items.Queue,
...result.data.items.Done,
].map((item) => `:${cmd} ${item.id}`);
}
if (cmd === "find" && (parts[1] || "").toLowerCase() === "remove") {
const project = state.project;
if (!project)
return [];
const r = readFindings(phrenPath, project);
if (!r.ok)
return [];
return r.data.map((item) => `:find remove ${item.id}`);
}
return commands;
}
// ── List items for each view ──────────────────────────────────────────────────
export function getListItems(phrenPath, profile, state, healthLineCount) {
switch (state.view) {
case "Projects": {
const cards = listProjectCards(phrenPath, profile);
return state.filter
? cards.filter((c) => `${c.name} ${c.summary} ${c.docs.join(" ")}`.toLowerCase().includes(state.filter.toLowerCase()))
: cards;
}
case "Tasks": {
if (!state.project)
return [];
const result = readTasks(phrenPath, state.project);
if (!result.ok)
return [];
const filtered = filterTaskDoc(phrenPath, result.data, { responsibility: state.taskResponsibility });
const active = state.filter ? tasksByFilter(filtered.items.Active, state.filter) : filtered.items.Active;
const queue = state.filter ? tasksByFilter(filtered.items.Queue, state.filter) : filtered.items.Queue;
return [...active, ...queue];
}
case "Findings": {
if (!state.project)
return [];
const result = readFindings(phrenPath, state.project);
if (!result.ok)
return [];
return state.filter
? result.data.filter((i) => `${i.id} ${i.date} ${i.text}`.toLowerCase().includes(state.filter.toLowerCase()))
: result.data;
}
case "Review Queue": {
if (!state.project)
return [];
const result = readReviewQueue(phrenPath, state.project);
if (!result.ok)
return [];
return state.filter ? queueByFilter(result.data, state.filter) : result.data;
}
case "Skills": {
if (!state.project)
return [];
const allSkills = getProjectSkills(phrenPath, state.project).map((s) => ({
name: s.name,
text: `${s.enabled ? "enabled" : "disabled"} · ${s.path}`,
path: s.path,
scopeType: s.scopeType,
storePath: s.storePath,
}));
return state.filter
? allSkills.filter((s) => `${s.name} ${s.text}`.toLowerCase().includes(state.filter.toLowerCase()))
: allSkills;
}
case "Hooks": {
return getHookEntries(phrenPath).map((e) => ({ name: e.event, text: e.enabled ? "active" : "inactive" }));
}
case "Health":
return Array.from({ length: Math.max(1, healthLineCount) }, (_, i) => ({ id: String(i) }));
case "Graph":
// The graph manages its own selection; the list cursor has nothing to walk.
return [];
default:
return [];
}
}
// ── Activation (Enter key) ────────────────────────────────────────────────────
async function activateSelected(host) {
const cursor = host.currentCursor();
const items = host.getListItems();
const item = items[cursor];
if (!item)
return;
switch (host.state.view) {
case "Projects":
if (item.name) {
host.state.project = item.name;
saveShellState(host.phrenPath, host.state);
host.setView("Tasks");
host.setMessage(` ${style.green("●")} ${style.boldCyan(item.name)}`);
}
break;
case "Tasks":
if (item.id) {
const project = host.ensureProjectSelected();
if (!project)
return;
const file = taskFileForProject(host.phrenPath, project);
host.confirmThen(`Complete ${style.dim(item.id)} "${item.line}"?`, () => {
host.snapshotForUndo(`complete ${item.id}`, file);
const r = completeTask(host.phrenPath, project, item.id);
host.invalidateSubsectionsCache();
host.setMessage(` ${resultMsg(r)}`);
host.setCursor(Math.max(0, cursor - 1));
});
}
break;
case "Findings":
if (item.text) {
host.setMessage(` ${style.dim(item.id ?? "")} ${item.text}`);
}
break;
case "Review Queue":
if (item.text) {
host.setMessage(` ${style.dim(item.id ?? "")} ${item.text} ${style.dim("[ read-only ]")}`);
}
break;
case "Skills":
if (item.name) {
host.setMessage(` ${style.bold(item.name)} ${style.dim(item.text ?? "")}`);
}
break;
case "Hooks":
if (item.name) {
host.setMessage(` ${item.text === "active" ? style.boldGreen("active") : style.dim("inactive")} ${style.bold(item.name)}`);
}
break;
}
}
// ── View-specific action keys ─────────────────────────────────────────────────
async function doViewAction(host, key) {
const cursor = host.currentCursor();
const items = host.getListItems();
const item = items[cursor];
// `e` hands the file to the user's own editor, whatever that is.
if (key === "e") {
const target = editTargetFor(host, item);
if (!target) {
host.setMessage(` ${style.dim("nothing to edit here")}`);
return;
}
const handed = await host.suspend(() => {
const result = openInEditor(target.path);
host.setMessage(result.ok
? ` ${style.green("✓")} ${target.label}`
: ` Editor "${result.command}" failed — ${result.error ?? "could not start"}. Set $EDITOR.`);
});
if (!handed)
host.setMessage(` ${style.dim("edit it at")} ${target.path}`);
host.invalidateSubsectionsCache();
return;
}
const project = host.state.project;
switch (host.state.view) {
case "Tasks":
if (key === "a") {
host.startInput("add", "");
}
else if (key === "d" && item?.id) {
if (!project) {
host.setMessage("Select a project first.");
return;
}
const file = taskFileForProject(host.phrenPath, project);
const taskResult = readTasks(host.phrenPath, project);
const isActive = taskResult.ok && taskResult.data.items.Active.some((i) => i.id === item.id);
const targetSection = isActive ? "Queue" : "Active";
host.snapshotForUndo(`move ${item.id} → ${targetSection.toLowerCase()}`, file);
const r = updateTask(host.phrenPath, project, item.id, { section: targetSection });
host.invalidateSubsectionsCache();
host.setMessage(` ${resultMsg(r)}`);
}
break;
case "Findings":
if (key === "a") {
host.startInput("learn-add", "");
}
else if ((key === "d" || key === "\x7f") && item?.text) {
if (!project) {
host.setMessage("Select a project first.");
return;
}
host.confirmThen(`Delete finding ${style.dim(item.id ?? "")}?`, () => {
const file = path.join(host.phrenPath, project, FINDINGS_FILENAME);
host.snapshotForUndo(`remove finding ${item.id ?? ''}`, file);
const r = removeFinding(host.phrenPath, project, item.text);
host.setMessage(` ${resultMsg(r)}`);
host.setCursor(Math.max(0, cursor - 1));
});
}
break;
case "Review Queue":
host.setMessage(" Review queue is read-only.");
break;
case "Skills":
if ((key === "d" || key === "\x7f") && item?.name) {
if (!project) {
host.setMessage("Select a project first.");
return;
}
const skillPath = item.path;
if (!skillPath) {
host.setMessage(" Could not resolve that skill's path.");
return;
}
host.confirmThen(`Remove skill "${item.name}"?`, () => {
try {
removeSkillPath(skillPath);
host.setMessage(` Removed ${item.name}`);
host.setCursor(Math.max(0, cursor - 1));
}
catch (err) {
host.setMessage(` Failed: ${errorMessage(err)}`);
}
});
}
else if (key === "t" && item?.name) {
if (!project) {
host.setMessage("Select a project first.");
return;
}
const isEnabled = !item.text?.startsWith("disabled");
// A global skill's enabled flag is recorded under the "global" scope;
// passing the project wrote a key that nothing ever reads back.
const scope = item.scopeType === "global" ? "global" : project;
setSkillEnabledAndSync(item.storePath ?? host.phrenPath, scope, item.name, !isEnabled);
host.setMessage(` ${!isEnabled ? "Enabled" : "Disabled"} ${item.name}`);
}
else if (key === "a") {
if (!project) {
host.setMessage("Select a project first.");
return;
}
host.startInput("skill-add", "");
}
break;
case "Hooks":
if (key === "a" || key === "d") {
const enable = key === "a";
writeInstallPreferences(host.phrenPath, { hooksEnabled: enable });
host.setMessage(` Hooks ${enable ? style.boldGreen("enabled") : style.dim("disabled")} — takes effect next session`);
}
break;
}
}
// ── Cursor position display ───────────────────────────────────────────────────
function showCursorPosition(host) {
const items = host.getListItems();
const count = items.length;
if (count === 0)
return;
const cursor = host.currentCursor();
const item = items[cursor];
const label = item?.name ?? item?.line ?? item?.text ?? "";
const short = label.length > 50 ? label.slice(0, 48) + "…" : label;
host.setMessage(` ${style.dim(`${cursor + 1} / ${count}`)}${short ? ` ${style.dimItalic(short)}` : ""}`);
}
// ── View shortcut keys (shared between handleInput text mode and handleNavigateKey) ─
/**
* Handle p/b/l/m/s/k/h shortcut keys that switch the active view.
* Returns true if the key was handled.
*/
export function applyViewShortcut(host, key) {
if (key === "p") {
host.setView("Projects");
host.setMessage(` ${TAB_ICONS.Projects} Projects`);
return true;
}
if (key === "b") {
if (!host.state.project) {
host.setMessage(style.dim(" Select a project first (↵)"));
return true;
}
host.setView("Tasks");
host.setMessage(` ${TAB_ICONS.Tasks} Tasks`);
return true;
}
if (key === "l") {
if (!host.state.project) {
host.setMessage(style.dim(" Select a project first (↵)"));
return true;
}
host.setView("Findings");
host.setMessage(` ${TAB_ICONS.Findings} Findings`);
return true;
}
if (key === "m") {
if (!host.state.project) {
host.setMessage(style.dim(" Select a project first (↵)"));
return true;
}
host.setView("Review Queue");
host.setMessage(` ${TAB_ICONS["Review Queue"]} Review Queue`);
return true;
}
if (key === "s") {
if (!host.state.project) {
host.setMessage(style.dim(" Select a project first (↵)"));
return true;
}
host.setView("Skills");
host.setMessage(` ${TAB_ICONS.Skills} Skills`);
return true;
}
if (key === "k") {
host.setView("Hooks");
host.setMessage(` ${TAB_ICONS.Hooks} Hooks`);
return true;
}
if (key === "h") {
host.prevHealthView = host.state.view === "Health" ? host.prevHealthView : host.state.view;
host.healthCache = undefined;
host.setView("Health");
host.setMessage(` ${TAB_ICONS.Health} Health ${style.dim("(esc to return)")}`);
return true;
}
// No message: the bottom bar already says this, and in the Graph view a
// spare row is worth more than a reminder.
if (key === "g") {
host.setView("Graph");
host.setMessage("");
return true;
}
return false;
}
// ── Navigate-mode key handler ─────────────────────────────────────────────────
export async function handleNavigateKey(host, rawKey) {
// Terminals in application-cursor mode send SS3 (ESC O A) instead of CSI
// (ESC [ A) for the arrow keys; normalise so both spellings navigate.
const key = /^\x1bO[A-D]$/.test(rawKey) ? `\x1b[${rawKey[2]}` : rawKey;
// The graph view owns arrows, Enter, /, Esc-layering and its own letters;
// anything it declines (q, :, ?, view shortcuts, a final Esc) falls through.
if (host.state.view === "Graph") {
const handled = host.graph().handleKey(key, host);
if (handled !== undefined)
return handled;
}
if (key === "\x1b[A") {
host.moveCursor(-1);
showCursorPosition(host);
return true;
}
if (key === "\x1b[B") {
host.moveCursor(1);
showCursorPosition(host);
return true;
}
if (key === "\x1b[D") {
if (host.state.view === "Projects") {
host.setMessage(` ${style.dim("Projects is the dashboard landing screen")}`);
}
else {
prevTab(host);
}
return true;
}
if (key === "\x1b[C") {
if (host.state.view === "Projects") {
host.setMessage(` ${style.dim("Press ↵ to enter the selected project's tasks")}`);
}
else {
nextTab(host);
}
return true;
}
if (key === "\x1b[5~") {
host.moveCursor(-10);
showCursorPosition(host);
return true;
}
if (key === "\x1b[6~") {
host.moveCursor(10);
showCursorPosition(host);
return true;
}
if (key === "\x1b[H" || key === "\x1b[1~") {
host.setCursor(0);
showCursorPosition(host);
return true;
}
if (key === "\x1b[F" || key === "\x1b[4~") {
host.setCursor(host.getListItems().length - 1);
showCursorPosition(host);
return true;
}
if (key === "\t") {
nextTab(host);
return true;
}
if (key === "\x1b[Z") {
prevTab(host);
return true;
}
if (key === "q" || key === "Q")
return false;
if (key === "\r" || key === "\n") {
await activateSelected(host);
return true;
}
if (key === "?") {
host.showHelp = !host.showHelp;
host.setMessage(host.showHelp ? " Showing help — ↑↓ to scroll, any other key to dismiss" : ` ${style.boldCyan("←→")} ${style.dim("tabs")} ${style.boldCyan("↑↓")} ${style.dim("move")} ${style.boldCyan("↵")} ${style.dim("activate")} ${style.boldCyan("?")} ${style.dim("help")}`);
return true;
}
if (key === "/") {
host.startInput("filter", host.filter || "");
return true;
}
if (key === ":") {
host.startInput("command", "");
return true;
}
if (key === "\x1b") {
if (host.filter) {
host.setFilter("");
}
else if (host.state.view === "Health") {
const returnTo = host.prevHealthView ?? "Projects";
host.setView(returnTo);
host.prevHealthView = undefined;
host.setMessage(` ${TAB_ICONS[returnTo] ?? TAB_ICONS.Projects} ${returnTo}`);
}
else if (host.state.view !== "Projects") {
host.setView("Projects");
host.setMessage(` ${TAB_ICONS.Projects} ${style.dim("dashboard")}`);
}
else {
host.setMessage(` ${style.dim("press")} ${style.boldCyan("q")} ${style.dim("to quit")}`);
}
return true;
}
if (applyViewShortcut(host, key))
return true;
if (key === "i" && host.state.view === "Projects") {
const next = host.state.introMode === "always" ? "once-per-version" : host.state.introMode === "off" ? "always" : "off";
host.state.introMode = next;
saveShellState(host.phrenPath, host.state);
host.setMessage(` Intro mode: ${style.boldCyan(next)}`);
return true;
}
if (key === "E") {
const items = host.getListItems();
const item = items[host.currentCursor()];
const target = editTargetFor(host, item);
if (!target) {
host.setMessage(` ${style.dim("nothing to edit here")}`);
return true;
}
const scope = target.kind === "skill" ? (item?.scopeType === "global" ? "global" : host.state.project) : undefined;
if (host.openEditor(target.path, target.label, target.kind, scope)) {
host.setMessage(` ${style.dim("i insert · :w write · :q quit")}`);
}
return true;
}
if (["a", "d", "e", "t", "\x7f"].includes(key)) {
await doViewAction(host, key);
return true;
}
return true;
}
// ── Tab switching ─────────────────────────────────────────────────────────────
function nextTab(host) {
if (host.state.view === "Projects" || host.state.view === "Health" || host.state.view === "Graph")
return;
const views = enabledSubViews(host.phrenPath, host.profile);
const idx = views.indexOf(host.state.view);
const next = views[(idx + 1) % views.length];
if (next) {
host.setView(next);
host.setMessage(` ${TAB_ICONS[next]} ${next}`);
}
}
function prevTab(host) {
if (host.state.view === "Projects" || host.state.view === "Health" || host.state.view === "Graph")
return;
const views = enabledSubViews(host.phrenPath, host.profile);
const idx = views.indexOf(host.state.view);
const prev = views[(idx - 1 + views.length) % views.length];
if (prev) {
host.setView(prev);
host.setMessage(` ${TAB_ICONS[prev]} ${prev}`);
}
}