SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
17.4 KB · 487 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/cli/commands.ts4 * Description: Slash-command implementations backed by real services — registered into the TUI command registry.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { ResolvedConfig } from "../config/index.js";11import { buildUsageTotals } from "../session/index.js";12import type { DurableEvent } from "../session/index.js";13import type { CommandDef } from "../tui/index.js";14import type { PaletteItem } from "../tui/index.js";15import type { Engine } from "./engine.js";16import { listSessions } from "./sessions.js";1718// ───────────────────────── dependencies ─────────────────────────1920/** The slice of the TUI the commands drive — structural, stubbable in tests. */21export interface CommandUi {22  printBlock(lines: string[]): void;23  openSelector(items: PaletteItem[], onSelect: (id: string) => void): void;24  setModelLabel(model: string): void;25}2627export interface CliCommandDeps {28  ui: CommandUi;29  /** Live engine accessor — the engine is rebuilt on /new and /resume. */30  engine(): Engine;31  config: ResolvedConfig;32  cwd: string;33  sessionsDir: string;34  projectHash: string;35  actions: {36    newSession(): void;37    resumeSession(sessionId: string): void;38    quit(): void;39    /** Consume the next composer submit as command input instead of a model turn. */40    captureNextSubmit?(consume: (text: string) => void): void;41  };42  /** Live model listing (SDK /v1/models); injectable for tests. */43  listModels?: () => Promise<{ id: string; displayName?: string }[]>;44}4546/** Every slash command the CLI layer provides (TUI built-ins add /diff-expand, /help, /quit). */47export const CLI_SLASH_COMMANDS: readonly string[] = [48  "/model",49  "/config",50  "/permissions",51  "/sessions",52  "/resume",53  "/new",54  "/rename",55  "/clear",56  "/compact",57  "/cost",58  "/context",59  "/diff",60  "/processes",61  "/status",62];6364// ───────────────────────── pure report builders ─────────────────────────6566/**67 * Session cost report from REAL usage events only (Absolute Rule #4):68 * every number is summed from ModelResponseCompleted.usage. No pricing is69 * configured in V1, so no dollar figure is invented.70 */71export function costReportLines(events: readonly DurableEvent[]): string[] {72  const usage = buildUsageTotals(events);73  const lines: string[] = ["", " cost · this session (real API usage)"];74  const fmt = (n: number): string => n.toLocaleString("en-US").padStart(12);75  const row = (label: string, u: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; requests: number }): string[] => [76    `   ${label}`,77    `     requests      ${String(u.requests).padStart(12)}`,78    `     input tokens  ${fmt(u.inputTokens)}`,79    `     output tokens ${fmt(u.outputTokens)}`,80    `     cache write   ${fmt(u.cacheWriteTokens)}`,81    `     cache read    ${fmt(u.cacheReadTokens)}`,82  ];83  for (const [model, perModel] of Object.entries(usage.perModel)) {84    lines.push(...row(model, perModel));85  }86  if (Object.keys(usage.perModel).length > 1) {87    lines.push(...row("total", usage.totals));88  }89  if (usage.totals.requests === 0) {90    lines.push("   no model requests yet");91  }92  lines.push("   cost estimate  n/a — no pricing configured (tokens above are exact)");93  return lines;94}9596function elapsed(sinceMs: number): string {97  const s = Math.max(0, Math.floor((Date.now() - sinceMs) / 1000));98  const mm = String(Math.floor(s / 60)).padStart(2, "0");99  const ss = String(s % 60).padStart(2, "0");100  return `${mm}:${ss}`;101}102103// ───────────────────────── command construction ─────────────────────────104105/** Build every CLI slash command. Pure with respect to the registry. */106export function buildCliCommands(deps: CliCommandDeps): CommandDef[] {107  const { ui } = deps;108  const print = (lines: string[]): void => {109    ui.printBlock(lines);110  };111  const printError = (what: string, error: unknown): void => {112    const message = error instanceof Error ? error.message : String(error);113    print(["", ` ${what} failed`, `   ${message}`]);114  };115  const runAsync = (what: string, fn: () => Promise<void>): void => {116    fn().catch((error: unknown) => {117      printError(what, error);118      deps.engine().logger.error(`${what} command failed`, {119        error: error instanceof Error ? error.message : String(error),120      });121    });122  };123124  const commands: CommandDef[] = [];125126  // /model — selector listing live models when reachable, config models otherwise.127  commands.push({128    id: "model.select",129    title: "Change model",130    slash: "/model",131    description: "switch the Anthropic model",132    run: () =>133      runAsync("/model", async () => {134        const engine = deps.engine();135        let models: { id: string; displayName?: string }[] = [];136        if (deps.listModels !== undefined) {137          try {138            models = await deps.listModels();139          } catch {140            models = [];141          }142        }143        if (models.length === 0) {144          const fallback = new Set([deps.config.model, deps.config.auxModel, engine.model]);145          models = [...fallback].map((id) => ({ id }));146        }147        const items: PaletteItem[] = models.map((m) => {148          const item: PaletteItem = { id: m.id, label: m.id };149          if (m.displayName !== undefined) item.detail = m.displayName;150          if (m.id === engine.model) item.detail = `${item.detail ?? ""} · current`.trim();151          return item;152        });153        ui.openSelector(items, (id) => {154          const current = deps.engine();155          if (current.turnActive) {156            print(["", " /model — cannot switch while a turn is running (Esc to interrupt first)"]);157            return;158          }159          current.switchModel(id, "user");160          ui.setModelLabel(id);161          print(["", ` model switched to ${id} (new prompt-cache lineage)`]);162        });163      }),164  });165166  // /config — redacted resolved configuration + sources.167  commands.push({168    id: "config.open",169    title: "Configuration",170    slash: "/config",171    description: "resolved configuration and sources",172    run: () => {173      const engine = deps.engine();174      const lines = [175        "",176        " configuration",177        `   model             ${engine.model}`,178        `   auxModel          ${deps.config.auxModel}`,179        `   thinking          ${deps.config.thinking}`,180        `   maxOutputTokens   ${deps.config.maxOutputTokens}`,181        `   apiKey            ${deps.config.hasApiKey ? "[redacted]" : "(not set)"}`,182        "   sources (highest precedence first)",183        ...(deps.config.sources.length > 0184          ? deps.config.sources.map((s) => `     ${s}`)185          : ["     defaults only"]),186        "   edit: .khaelor/config.json (project) · ~/.khaelor/config.json (user)",187      ];188      print(lines);189    },190  });191192  // /permissions — the effective rule stack.193  commands.push({194    id: "permissions.open",195    title: "Permissions",196    slash: "/permissions",197    description: "effective permission rules",198    run: () => {199      const rules = deps.engine().permissions.effectiveRules();200      const lines = ["", " permissions · effective rules (later rules win)"];201      for (const rule of rules) {202        const pattern = rule.pattern ?? "*";203        lines.push(204          `   ${rule.action.padEnd(5)} ${rule.capability.padEnd(26)} ${pattern.padEnd(24)} ${rule.source ?? "default"}`,205        );206      }207      lines.push(`   ${rules.length} rules · unmatched destructive capabilities ask`);208      print(lines);209    },210  });211212  // /sessions — list from the session directory.213  commands.push({214    id: "sessions.open",215    title: "Sessions",216    slash: "/sessions",217    description: "list this project's sessions",218    run: () => {219      const sessions = listSessions(deps.sessionsDir, deps.projectHash);220      const current = deps.engine().session.sessionId;221      const lines = ["", ` sessions · ${deps.cwd}`];222      if (sessions.length === 0) lines.push("   none yet");223      for (const s of sessions.slice(0, 20)) {224        const marker = s.sessionId === current ? "●" : " ";225        const when = new Date(s.updatedAt).toISOString().slice(0, 16).replace("T", " ");226        lines.push(` ${marker} ${s.sessionId}  ${when}  ${s.preview}`);227      }228      lines.push("   /resume opens a session picker");229      print(lines);230    },231  });232233  // /resume — picker over recorded sessions; resume = replay.234  commands.push({235    id: "sessions.resume",236    title: "Resume session",237    slash: "/resume",238    description: "resume a previous session (replay)",239    run: () => {240      const current = deps.engine().session.sessionId;241      const sessions = listSessions(deps.sessionsDir, deps.projectHash).filter(242        (s) => s.sessionId !== current,243      );244      if (sessions.length === 0) {245        print(["", " no other sessions to resume in this project"]);246        return;247      }248      const items: PaletteItem[] = sessions.slice(0, 30).map((s) => ({249        id: s.sessionId,250        label: s.preview,251        detail: s.sessionId,252      }));253      ui.openSelector(items, (id) => {254        deps.actions.resumeSession(id);255      });256    },257  });258259  // /new and /clear — a fresh session; logs are never truncated (§5.5).260  commands.push({261    id: "session.new",262    title: "New session",263    slash: "/new",264    description: "start a fresh session",265    run: () => deps.actions.newSession(),266  });267  // /rename — retitle the current session (§8). The next composer submit is the title.268  commands.push({269    id: "session.rename",270    title: "Rename session",271    slash: "/rename",272    description: "rename the current session",273    run: () => {274      const capture = deps.actions.captureNextSubmit;275      if (capture === undefined) {276        print(["", " /rename is unavailable in this mode"]);277        return;278      }279      print(["", " rename — type the new session title and press Enter (empty cancels)"]);280      capture((text) => {281        const title = text.trim();282        if (title === "") {283          print([" rename cancelled"]);284          return;285        }286        deps.engine().session.publishDurable({287          type: "session.renamed",288          payload: { title },289        });290        print([` session renamed · ${title}`]);291      });292    },293  });294  commands.push({295    id: "session.clear",296    title: "Clear",297    slash: "/clear",298    description: "start a fresh session (the old log is kept)",299    run: () => deps.actions.newSession(),300  });301302  // /compact — manual compaction: prune first, then summarize-compact.303  commands.push({304    id: "context.compact",305    title: "Compact context",306    slash: "/compact",307    description: "compact the conversation context now",308    run: () =>309      runAsync("/compact", async () => {310        const engine = deps.engine();311        if (engine.turnActive) {312          print(["", " /compact — wait for the current turn to finish (or Esc to interrupt)"]);313          return;314        }315        const events = engine.session.events();316        const prune = engine.contextEngine.pruneToolResults({ events });317        if (prune.toolUseIds.length > 0) {318          engine.session.publishDurable({ type: "context.pruned", payload: prune });319          print([320            "",321            ` context pruned · ${prune.toolUseIds.length} old tool results blanked`,322            `   ~${prune.tokensReclaimedEstimate.toLocaleString("en-US")} tokens reclaimed (estimate)`,323          ]);324          return;325        }326        const checkpoint = await engine.contextEngine.compress({ events });327        engine.session.publishDurable({ type: "context.compacted", payload: checkpoint });328        print([329          "",330          ` context compacted · events ${checkpoint.cut.fromSeq}–${checkpoint.cut.toSeq} → checkpoint`,331          `   summary model ${checkpoint.summaryModel} · trigger ${checkpoint.trigger}`,332        ]);333      }),334  });335336  // /cost — real usage projection over the durable log.337  commands.push({338    id: "cost.show",339    title: "Show cost",340    slash: "/cost",341    description: "session token usage from real API data",342    run: () => {343      print(costReportLines(deps.engine().session.events()));344    },345  });346347  // /context — budget breakdown from the Context Engine.348  commands.push({349    id: "context.open",350    title: "Context inspector",351    slash: "/context",352    description: "context budget breakdown",353    run: () =>354      runAsync("/context", async () => {355        const engine = deps.engine();356        const built = await engine.contextEngine.selectContext({357          events: engine.session.events(),358        });359        const budget = engine.budget;360        const lines = ["", " context · section estimates (~4 chars/token)"];361        for (const section of built.stats.sections) {362          lines.push(363            `   ${section.name.padEnd(28)} ~${section.estimatedTokens.toLocaleString("en-US").padStart(9)} tokens`,364          );365        }366        lines.push(367          `   ${"total (estimated)".padEnd(28)} ~${built.stats.estimatedInputTokens.toLocaleString("en-US").padStart(9)} tokens`,368        );369        lines.push(370          `   window ${budget.modelWindow.toLocaleString("en-US")} · usable ${budget.usableWindow.toLocaleString("en-US")} (output + compaction reserve held back)`,371        );372        if (budget.hasObservedUsage) {373          lines.push(374            `   last real total ${budget.lastTotalTokens.toLocaleString("en-US")} tokens · pressure ${(budget.pressure() * 100).toFixed(0)}%`,375          );376        } else {377          lines.push("   no real usage observed yet this session");378        }379        print(lines);380      }),381  });382383  // /diff — working-tree changes vs the recorded baseline, attributed.384  commands.push({385    id: "diff.show",386    title: "View diff",387    slash: "/diff",388    key: "d",389    description: "working-tree changes since the session baseline",390    run: () =>391      runAsync("/diff", async () => {392        const engine = deps.engine();393        const [diff, attribution] = await Promise.all([394          engine.git.diff(),395          engine.git.attributeChanges(),396        ]);397        if (diff.kind === "not-a-repo") {398          print(["", " /diff — not a git repository"]);399          return;400        }401        if (diff.kind === "error") {402          print(["", ` /diff — git failed: ${diff.message}`]);403          return;404        }405        const khaelor = new Set(attribution.kind === "ok" ? attribution.value.khaelor : []);406        const preExisting = new Set(407          attribution.kind === "ok" ? attribution.value.preExisting : [],408        );409        const lines = ["", ` diff · vs ${diff.value.base}`];410        if (diff.value.entries.length === 0) lines.push("   working tree clean");411        for (const entry of diff.value.entries) {412          const added = entry.added === null ? "bin" : `+${entry.added}`;413          const removed = entry.removed === null ? "" : `−${entry.removed}`;414          const who = khaelor.has(entry.path)415            ? "khaelor"416            : preExisting.has(entry.path)417              ? "pre-existing"418              : "";419          lines.push(420            `   ${entry.status.padEnd(9)} ${entry.path.padEnd(44)} ${`${added} ${removed}`.padEnd(12)} ${who}`,421          );422        }423        lines.push("   press d after an edit to expand its unified diff");424        print(lines);425      }),426  });427428  // /processes — the background process manager.429  commands.push({430    id: "processes.open",431    title: "Show processes",432    slash: "/processes",433    description: "background processes",434    run: () => {435      const list = deps.engine().processes.list();436      const lines = ["", " processes"];437      if (list.length === 0) lines.push("   none");438      for (const proc of list) {439        const glyph = proc.status === "running" ? "●" : "○";440        const status =441          proc.status === "running"442            ? `running   ${elapsed(proc.startedAt)}`443            : `${proc.status}    ${proc.exitCode === null ? "" : `code ${proc.exitCode}`}`;444        lines.push(` ${glyph} ${proc.id.padEnd(5)} ${proc.command.slice(0, 40).padEnd(42)} ${status}`);445      }446      print(lines);447    },448  });449450  // /status — where this session stands.451  commands.push({452    id: "status.show",453    title: "Status",454    slash: "/status",455    description: "session status",456    run: () =>457      runAsync("/status", async () => {458        const engine = deps.engine();459        const events = engine.session.events();460        const branch = await engine.git.currentBranch();461        const running = engine.processes.list().filter((p) => p.status === "running").length;462        print([463          "",464          " status",465          `   session   ${engine.session.sessionId}`,466          `   model     ${engine.model}`,467          `   cwd       ${deps.cwd}`,468          `   branch    ${branch.kind === "ok" ? branch.value : "(not a repo)"}`,469          `   events    ${events.length} durable`,470          `   turn      ${engine.turnActive ? "running" : "idle"}`,471          `   processes ${running} running`,472          `   log       ${engine.log.filePath}`,473        ]);474      }),475  });476477  return commands;478}479480/** Register every CLI command into the TUI registry (overrides Phase-2 placeholders by id). */481export function registerCliCommands(482  registry: { register(def: CommandDef): void },483  deps: CliCommandDeps,484): void {485  for (const def of buildCliCommands(deps)) registry.register(def);486}487