/** * The `/bytefly` surface: one command, `/bytefly [doctor] [settings] [skills] [update] [upgrade] [extension] [refresh]`. * * Without a section word it opens the extension screen: the dock in `tui`, the * grouped listing elsewhere. `settings` opens the Settings section, or prints * its grouped listing outside `tui` without resolving packages. `skills` opens * the Skills section and lists the resolved skills in text modes. `update` * re-runs the published-version check and reports what it found, which is the * one place a request is made on demand; `upgrade` asks Pi's own package manager * to install what that check found, behind a confirmation, so the two verbs stay * one job each — ask, and act. With `doctor` it produces the doctor * report — the same text `run_doctor` returns — for the whole set or one named * extension; in `tui` the report is the dock screen. Discovery, the doctor * probe, and the dirty-to-reload confirm all live here. The doctor runtime and * the update handle are injected by the composition root, so `run_doctor` and * the screen keep reading one cache, and the command paints the marker it finds. */ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import pkg from "../../../package.json"; import { sanitizeLine } from "../../../lib/format.ts"; import { REQUIREMENTS, type ExtensionReport } from "../../../lib/requirements.ts"; import { formatExtension, formatReport, hasProblems } from "../doctor/format.ts"; import type { DoctorRuntime } from "../doctor/runtime.ts"; import { updateOutcomeText } from "../update/format.ts"; import type { UpdateHandle } from "../update/register.ts"; import type { DiscoveryDeps } from "../picker/discovery.ts"; import type { PickerRuntime } from "../picker/runtime.ts"; import type { Discovery } from "../picker/types.ts"; import { usageRecords } from "../footer/collect/entries.ts"; import { loadByteflyConfig } from "../footer/price/config.ts"; import { createPricer } from "../footer/price/price.ts"; import type { SettingsRuntime, SettingsRuntimeDeps } from "./settings-runtime.ts"; import type { SkillIndex } from "./skills.ts"; import type { PanelSection } from "./types.ts"; /** * What `/bytefly` reads once it opens, imported on demand rather than at registration. * The listing, its prices, and the theme names are this command's alone, and a start * pays every static import of every entry. */ type PanelKit = typeof import("./kit.ts"); let panelKit: Promise | undefined; /** The panel's listing helpers, loaded the first time `/bytefly` opens a surface. */ function loadPanelKit(): Promise { panelKit ??= import("./kit.ts"); return panelKit; } /** Host seams for the panel command, injected by tests and the composition root. */ export interface PanelCommandDeps { /** The doctor runtime created by `registerDoctor`; shared with `run_doctor`. */ doctor: DoctorRuntime; /** The update handle `registerUpdate` returns, for `/bytefly update`. */ update: UpdateHandle; /** The picker's discovery seam. */ discover?: (deps: DiscoveryDeps) => Promise; /** The Settings runtime seam. */ settings?: (deps: SettingsRuntimeDeps) => SettingsRuntime; /** The skill-metadata seam; `loadSkillIndex` unless a test supplies one. */ skills?: (paths: readonly string[], cwd: string, agentDir: string) => SkillIndex; } /** * Names `/bytefly ` accepts: every declared extension, plus the directory * of any resource in either discovery view. Reading both views keeps a * project-only name valid before the screen has switched to Project scope. */ async function selectableNames(discovery: Discovery): Promise> { const { extensionName } = await loadPanelKit(); const names = new Set(Object.keys(REQUIREMENTS)); for (const item of [...discovery.extensions.global, ...discovery.extensions.effective]) { names.add(extensionName(item.path)); } return names; } /** The values a bare `/bytefly ` offers, in screen order. */ const FIRST_TOKENS = [ "doctor", "prices", "settings", "skills", "update", "upgrade", ...Object.keys(REQUIREMENTS), "refresh", ] as const; /** * Argument completions. The host replaces the whole argument text with the * returned `value`, so a second token returns the full `doctor ` or * ` refresh` argument rather than just the word. `/bytefly settings`, * `/bytefly skills`, `/bytefly update`, and `/bytefly upgrade` take no name, and * `refresh` is never followed by another token. */ function completions(prefix: string): Array<{ value: string; label: string }> | null { const [first = "", second, ...rest] = prefix.trimStart().split(/\s+/); if (rest.length > 0) return null; if (second === undefined) { const options = FIRST_TOKENS.filter((option) => option.startsWith(first)); return options.length > 0 ? options.map((option) => ({ value: `${option} `, label: option })) : null; } if (first === "settings" || first === "skills" || first === "refresh" || first === "update" || first === "upgrade") return null; const options = (first === "doctor" ? [...Object.keys(REQUIREMENTS), "refresh"] : ["refresh"]).filter((option) => option.startsWith(second), ); return options.length > 0 ? options.map((option) => ({ value: `${first} ${option}`, label: option })) : null; } /** Which bytefly surface the arguments select. */ type PanelMode = "screen" | "doctor" | "update" | "upgrade" | "prices"; /** `/bytefly [doctor] [settings] [extension] [refresh]`, parsed. */ interface PanelArgs { mode: PanelMode; /** The section the screen opens on. */ section: PanelSection; /** The extension to narrow to, when one is named. */ name?: string; /** Re-probe the doctor instead of reusing the session cache. */ refresh: boolean; } /** Parse `/bytefly [doctor] [settings] [skills] [update] [extension] [refresh]`; extra tokens are ignored. */ function parseArgs(args: string): PanelArgs { const parts = args.trim().split(/\s+/).filter(Boolean); const refresh = parts.includes("refresh"); const rest = parts.filter((part) => part !== "refresh"); if (rest[0] === "doctor") return { mode: "doctor", section: "extensions", name: rest[1], refresh }; // `update` is already a refresh of the published version, so a stray `refresh` // beside it is accepted and ignored rather than refused. if (rest[0] === "update") return { mode: "update", section: "extensions", refresh }; // `upgrade` is the same subject as `update`, one verb further: it installs what a // check found. Refusing to fold them into one word is what keeps a command that // writes behind a confirmation instead of behind an argument. if (rest[0] === "upgrade") return { mode: "upgrade", section: "extensions", refresh }; // `prices` answers a question about this session's totals, so it takes no name and // no refresh: it prices what the session holds, once, when asked. if (rest[0] === "prices") return { mode: "prices", section: "extensions", refresh }; if (rest[0] === "settings") return { mode: "screen", section: "settings", name: rest[1], refresh }; if (rest[0] === "skills") return { mode: "screen", section: "skills", name: rest[1], refresh }; return { mode: "screen", section: "extensions", name: rest[0], refresh }; } /** * Print one extension's full findings, or say why it has none. The screen's * named form and the `doctor` subcommand share this text path. */ async function notifyExtension( ctx: ExtensionCommandContext, name: string, reports: readonly ExtensionReport[], runtime: PickerRuntime, ): Promise { const { extensionName } = await loadPanelKit(); const report = reports.find((entry) => entry.extension === name); if (report) { ctx.ui.notify(formatExtension(report), hasProblems([report]) ? "warning" : "info"); return; } const item = runtime.items("extensions").find((entry) => extensionName(entry.path) === name); ctx.ui.notify( item ? `bytefly: ${sanitizeLine(item.path)} — no doctor report` : `bytefly: no extension named "${sanitizeLine(name)}"`, "info", ); } /** * Theme names the row offers: the host's resolved list, narrowed to this * package's own palettes. The list is empty outside the TUI (RPC, print, JSON), * where the row still shows the stored value. */ async function settingsThemeNames(ctx: ExtensionCommandContext): Promise { try { const { shippedThemeNames } = await loadPanelKit(); return shippedThemeNames(ctx.ui.getAllThemes()); } catch { return []; } } /** The state one `/bytefly` invocation carries into the branch that answers it. */ interface PanelRun { pi: ExtensionAPI; deps: PanelCommandDeps; ctx: ExtensionCommandContext; cwd: string; agentDir: string; trusted: boolean; themes: string[]; section: PanelSection; name: string | undefined; refresh: boolean; mode: PanelMode; } /** The mounted panel, so a notice before the screen exists still reaches the transcript. */ interface PanelHolder { current: InstanceType | undefined; } /** `/bytefly prices`: one question about the transcript, no discovery. */ async function pricesAnswer(ctx: ExtensionCommandContext, cwd: string): Promise { const { pricesReport } = await loadPanelKit(); const config = loadByteflyConfig(cwd); const active = ctx.model ? { provider: ctx.model.provider, model: ctx.model.id } : undefined; const scanned = usageRecords(ctx.sessionManager.getEntries(), active); ctx.ui.notify(pricesReport(createPricer(config)(scanned.records), config.currency).join("\n"), "info"); } /** `/bytefly update` and `/bytefly upgrade`: the check, and the install it offers. */ async function updateAnswer( deps: PanelCommandDeps, mode: "update" | "upgrade", ctx: ExtensionCommandContext, ): Promise { if (mode === "update") { const outcome = await deps.update.recheck(); const { text, severity } = updateOutcomeText(outcome, pkg.version); ctx.ui.notify(text, severity); return; } const confirmed = await ctx.ui.confirm( "Update packages?", `Run Pi's package manager for every configured package, then reload to use it.`, ); if (!confirmed) { ctx.ui.notify("bytefly: update cancelled.", "info"); return; } const outcome = await deps.update.apply(); const { text, severity } = updateOutcomeText(outcome, pkg.version); ctx.ui.notify(text, severity); } /** Settings outside `tui`: the grouped listing without resolving packages. */ async function settingsText( run: PanelRun, makeSettings: NonNullable, createSettingsStores: (cwd: string, agentDir: string, trusted: boolean) => Discovery["stores"], surfaceNotice: (message: string, kind?: "warning" | "error") => void, ): Promise { const { formatSettingsListing } = await loadPanelKit(); const stores = createSettingsStores(run.cwd, run.agentDir, run.trusted); const settings = makeSettings({ stores, cwd: run.cwd, agentDir: run.agentDir, trusted: run.trusted, themes: run.themes, onError: (message) => surfaceNotice(message, "warning"), }); run.ctx.ui.notify(formatSettingsListing(settings.views("global")), "info"); } /** Skills outside `tui`: the grouped listing from Pi's metadata, with no probe. */ async function skillsText( run: PanelRun, runtime: PickerRuntime, discovery: Discovery, loadSkills: (paths: readonly string[], cwd: string, agentDir: string) => SkillIndex, resolveSkillPaths: (discovery: Discovery) => string[], ): Promise { const { buildSkillModel, formatListing, SKILL_NOUN } = await loadPanelKit(); const index = loadSkills(resolveSkillPaths(discovery), run.cwd, run.agentDir); const model = buildSkillModel({ items: runtime.items("skills"), index, agentDir: run.agentDir }); run.ctx.ui.notify( formatListing(model, runtime.scope(), { noun: SKILL_NOUN, emptyLabel: "No skills found." }), "info", ); } /** Text modes: probe now, then print the report, a named extension, or the listing. */ async function reportText(run: PanelRun, runtime: PickerRuntime): Promise { let reports: ExtensionReport[]; try { reports = await run.deps.doctor.probeAll({ refresh: run.refresh }); } catch (error) { run.ctx.ui.notify(`Could not probe extensions: ${error instanceof Error ? error.message : String(error)}`, "error"); return; } if (run.name !== undefined) { await notifyExtension(run.ctx, run.name, reports, runtime); return; } if (run.mode === "doctor") { run.ctx.ui.notify(formatReport(reports), hasProblems(reports) ? "warning" : "info"); return; } const { buildPanelModel, formatListing } = await loadPanelKit(); const model = buildPanelModel({ items: runtime.items("extensions"), reports, agentDir: run.agentDir }); run.ctx.ui.notify(formatListing(model, runtime.scope()), "info"); } /** The TUI branch: mount the screen, then apply any pending write. */ async function mountPanel( run: PanelRun, state: { runtime: PickerRuntime; settings: SettingsRuntime; discovery: Discovery; loadSkills: (paths: readonly string[], cwd: string, agentDir: string) => SkillIndex; resolveSkillPaths: (discovery: Discovery) => string[]; cached: ExtensionReport[] | undefined; holder: PanelHolder; }, ): Promise { const { runtime, settings, discovery, loadSkills, resolveSkillPaths, cached, holder } = state; const { ctx, section, name, refresh, agentDir, cwd } = run; let reports: ExtensionReport[] = cached ?? []; const { PanelComponent } = await import("./tui.ts"); const { withRailsSuppressed } = await import("../../../lib/rails.ts"); // The dock screen owns the editor slot; hide the rails for its lifetime. await withRailsSuppressed(run.pi, () => ctx.ui.custom((tui, theme, _keybindings, done) => { const unsubscribe = runtime.onChange(() => tui.requestRender()); // The settings runtime re-reads its snapshot after a write; without a // subscriber that repaint depends on every write chain's own callback. const unsubscribeSettings = settings.onChange(() => tui.requestRender()); const component = new PanelComponent({ runtime, reports: () => reports, settings, filter: section === "extensions" ? name : undefined, section, theme, agentDir, cwd, // A cached session probes only when the command asked for it. needsProbe: cached === undefined || refresh, initialRefresh: refresh, viewportRows: () => tui.terminal?.rows, requestRender: () => tui.requestRender(), skills: () => loadSkills(resolveSkillPaths(discovery), cwd, agentDir), refresh: async (force) => { reports = await run.deps.doctor.probeAll({ refresh: force === true }); }, reload: async () => { // The reload takes the session, so the screen asks first and says // what will change; a refusal leaves the dock exactly as it was. const reload = await ctx.ui.confirm("Reload to apply changes?", "Changes take effect after a reload."); if (!reload) return; await ctx.reload(); }, onClose: () => { // A late error falls back to the transcript, never a disposed screen. holder.current = undefined; unsubscribe(); unsubscribeSettings(); done(); }, }); holder.current = component; component.prime(); return component; }), ); if (!runtime.isDirty() && !settings.isDirty()) return; const reload = await ctx.ui.confirm("Reload to apply changes?", "Changes take effect after a reload."); if (reload) await ctx.reload(); } /** Register the single `/bytefly` command. */ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void { const handler = async (args: string, ctx: ExtensionCommandContext): Promise => { const { mode, section, name, refresh } = parseArgs(args); // Only the Extensions section takes an extension argument; a stray word is a // typo, not a filter, so say so rather than ignoring it. if (section !== "extensions" && name !== undefined) { ctx.ui.notify(`bytefly: /bytefly ${section} takes no extension name (got "${sanitizeLine(name)}").`, "warning"); return; } const cwd = ctx.cwd ?? process.cwd(); // `/bytefly prices` answers one question about the session and resolves nothing: it // prices what the transcript already recorded and names what that left out. It runs // in every mode, because the footer that would carry the same fact exists only in // `tui`. if (mode === "prices") return pricesAnswer(ctx, cwd); const agentDir = getAgentDir(); const trusted = ctx.isProjectTrusted(); const themes = await settingsThemeNames(ctx); // The mounted screen owns its feedback: a refusal or a failed write belongs in // the frame, not appended to the transcript behind it. Before the screen // exists (the non-TUI settings path, a pre-mount failure) the transcript is // the only surface. const holder: PanelHolder = { current: undefined }; const surfaceNotice = (message: string, kind: "warning" | "error" = "warning"): void => { if (holder.current) holder.current.notify(message); else ctx.ui.notify(message, kind); }; const settingsNotice = (message: string): void => surfaceNotice(message, "warning"); const run: PanelRun = { pi, deps, ctx, cwd, agentDir, trusted, themes, section, name, refresh, mode }; // `/bytefly update` answers one question and resolves nothing; `upgrade` is the // same subject one verb further, behind a confirmation because it writes. if (mode === "update" || mode === "upgrade") return updateAnswer(deps, mode, ctx); // Settings, discovery, and the picker are the command's, not registration's: the // price, update, and upgrade answers above resolve none of them. const makeSettings = deps.settings ?? (await import("./settings-runtime.ts")).createSettingsRuntime; const { createSettingsStores, discoverResources } = await import("../picker/discovery.ts"); const discover = deps.discover ?? discoverResources; // Settings outside `tui` prints its grouped listing without resolving // packages or probing the doctor. if (section === "settings" && ctx.mode !== "tui") { await settingsText(run, makeSettings, createSettingsStores, surfaceNotice); return; } let discovery: Discovery; try { discovery = await discover({ cwd, agentDir, trusted }); } catch (error) { ctx.ui.notify(`Could not list resources: ${error instanceof Error ? error.message : String(error)}`, "error"); return; } const settings = makeSettings({ stores: discovery.stores, cwd, agentDir, trusted: discovery.trusted, themes, onError: settingsNotice, }); const { createPickerRuntime } = await import("../picker/runtime.ts"); const runtime = createPickerRuntime({ discovery, cwd, agentDir, onError: (message) => surfaceNotice(message, "error"), }); // The name is checked before the probe: a typo should not pay for every // prerequisite check, and the set is available as soon as discovery is. if (name !== undefined && section === "extensions" && !(await selectableNames(discovery)).has(name)) { ctx.ui.notify(`bytefly: no extension named "${sanitizeLine(name)}"`, "warning"); return; } const { loadSkillIndex, resolvedSkillPaths } = await import("./skills.ts"); const loadSkills = deps.skills ?? loadSkillIndex; // Skills outside `tui` print the same grouped listing, from the metadata Pi's // loader reads; there is no report to probe for and no package to resolve. if (section === "skills" && ctx.mode !== "tui") { await skillsText(run, runtime, discovery, loadSkills, resolvedSkillPaths); return; } // Text modes print the report, so they probe now. The dock screen opens on // the session cache and probes on its first Extensions view instead, which // keeps `/bytefly` and `/bytefly settings` instant. const cached = deps.doctor.peek(); if (ctx.mode !== "tui") { await reportText(run, runtime); return; } await mountPanel(run, { runtime, settings, discovery, loadSkills, resolveSkillPaths: resolvedSkillPaths, cached, holder, }); }; pi.registerCommand("bytefly", { description: "Open the bytefly control center: /bytefly [extension] [refresh] lists extensions, /bytefly skills lists skills, /bytefly settings opens settings; /bytefly doctor [extension] [refresh] prints the doctor report", getArgumentCompletions: completions, handler, }); }