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%
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