diff --git a/docs/architecture.md b/docs/architecture.md index 568ea16..0c7b1fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -205,17 +205,22 @@ coding tools, then renders the result to text, JSONL, or pi-tui. `--resume`, `--session `, and the `cua-browser` custom entry. - `harness-named-sessions.ts` — persisted Kernel browser metadata for `cua session start|stop|list|show` and `-s `. -- `harness-skills.ts` — pi `loadSkills` over `~/.agents/skills`, - `/.agents/skills`, and `--skill `; `/skill:` - expansion. +- `harness-skills.ts` — skill and context discovery via pi's + `DefaultResourceLoader` (the loader pi's own TUI uses), so the set + includes pi-installed packages plus `~/.agents/skills`, + `/.agents/skills`, the pi agent dir, and `--skill `. + pi extensions are not loaded — cua drives the lower-level + `AgentHarness`, which cannot bind pi `AgentSession` extensions. + Also handles `/skill:` expansion. - `action/` — constrained one-shot prompts (`open|click|type|press|observe|url|screenshot|do`) and a bounded harness-driven runner. - `print.ts` — single-shot `--print` text output. - `output/harness-jsonl.ts` — JSONL event sink for `-o jsonl`. -- `tui/` — pi-tui 0.79 interactive front-end: `Markdown` message list, - `Image` screenshot widget, status line, telemetry footer, `Editor` - with autocomplete-backed slash commands (`/model`, `/thinking`, - `/compact`, `/skill:`). +- `tui/` — pi-tui 0.79 interactive front-end styled with pi's theme + system: `Markdown` message list, `Image` screenshot widget, status + line, telemetry footer, `Editor` with autocomplete-backed slash + commands (`/model`, `/thinking`, `/compact`, `/skill:`), and a + startup preamble with `[Context]` and `[Skills]` sections. ### `@onkernel/ptywright` @@ -258,16 +263,18 @@ The CLI's `buildCuaHarness` in `packages/cli/src/harness.ts`: `@onkernel/cua-ai`. 2. Wires `JsonlSessionRepo` and a `Session` for transcript persistence and resume. -3. Loads pi skills from `~/.agents/skills`, `/.agents/skills`, and - any `--skill ` flags and exposes them via `resources.skills`. +3. Discovers skills and context files through pi's + `DefaultResourceLoader` (installed packages, `~/.agents/skills`, + `/.agents/skills`, the pi agent dir, and `--skill `) and + exposes the skills via `resources.skills`. 4. Provides `extraTools` from `createCodingTools(cwd)` (`@earendil-works/pi-coding-agent`) for bash/read/edit/write/grep/find/ls. 5. Resolves the API key via `requireCuaEnvApiKeyForModel(ref)` and spreads any `_BASE_URL` env override onto the model object. 6. Composes the `systemPrompt` callback from - `resolveCuaRuntimeSpec(model).defaultSystemPrompt` plus - `formatSkillsForSystemPrompt(resources.skills)` so it stays correct - across `setModel()`. + `resolveCuaRuntimeSpec(model).defaultSystemPrompt`, + `formatSkillsForSystemPrompt(resources.skills)`, and the loaded + context files so it stays correct across `setModel()`. ## Component map @@ -289,7 +296,7 @@ flowchart LR aiPkg --> piAi harness --> coding["bash/read/edit/write/... (pi-coding-agent)"] harness --> sessions["JsonlSessionRepo (re-exported from cua-agent)"] - harness --> skillsMod["loadSkills (re-exported from cua-agent)"] + harness --> skillsMod["DefaultResourceLoader (pi-coding-agent): skills + context"] cli --> tui["cli/src/tui/main.ts (pi-tui)"] cli --> jsonl["cli/src/output/harness-jsonl.ts"] pty["ptywright (dev/test only)"] --> tui diff --git a/packages/cli/README.md b/packages/cli/README.md index cd2f682..3ae0fe2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -135,14 +135,19 @@ For named sessions, the exact transcript path is in [Session transcripts section in the top-level README](../../README.md#session-transcripts) for the JSONL schema and `jq` analysis examples. -## Skills +## Skills and context -`cua` follows the cross-agent -[`~/.agents/skills/`](https://agentskills.io) standard. Discovery -defaults: +`cua` resolves skills and context files through pi's resource loader +(the same loader pi's own TUI uses), so the discovery set matches pi. +Skills load from: -- `~/.agents/skills/` (user-global) +- `~/.agents/skills/` (user-global, the cross-agent + [`~/.agents/skills/`](https://agentskills.io) standard) - `/.agents/skills/` (project-local) +- the pi agent dir (`~/.pi/agent/`) +- pi-installed packages (`pi install …` records the package in pi's + settings and clones it under the agent dir; its bundled skills load + here too) Plus any explicit `--skill ` flags. Disable with `--no-skills` (`-ns`). @@ -152,6 +157,16 @@ the system prompt; the model uses the `read` tool to load a skill's full body when its description matches the task. Use `/skill:` in a prompt to force-load a skill body inline. +Context files (`AGENTS.md` / `CLAUDE.md`) discovered by the resource +loader are appended to the system prompt and listed in the TUI's +`[Context]` section. `--no-skills` disables skill discovery only; +context files still load, since they describe the project rather than +add agent capabilities. + +pi *extensions* are not executed by `cua`: extensions bind into pi's +`AgentSession`, and `cua` drives the lower-level `AgentHarness` +directly. Installed-package skills and context still load. + ## Image protocol Force the inline-screenshot protocol with `--image-protocol` or diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts index 5182efa..7c7d806 100644 --- a/packages/cli/src/cli-harness.ts +++ b/packages/cli/src/cli-harness.ts @@ -43,7 +43,7 @@ import { readMetadataFromFile, resolveSessionRef, } from "./harness-sessions"; -import { discoverCuaSkills } from "./harness-skills"; +import { type ContextFile, discoverCuaSkills } from "./harness-skills"; import { runPrint } from "./print"; const MODELS_HELP = `cua models — list supported -m/--model values @@ -341,6 +341,7 @@ interface HarnessRuntime { resolved: ResolvedSession | undefined; session: Session; skills: Skill[]; + contextFiles: ContextFile[]; harness: ReturnType; provider: string; modelRef: CuaModelRef; @@ -363,7 +364,7 @@ async function setupHarnessRuntime( const auth = resolveAuth(flags); const cwd = process.cwd(); const env = new NodeExecutionEnv({ cwd }); - const { skills } = await discoverCuaSkills({ + const { skills, contextFiles } = await discoverCuaSkills({ cwd, env, extraPaths: flags.skillPaths, @@ -410,6 +411,7 @@ async function setupHarnessRuntime( session, model: auth.modelRef, skills, + contextFiles, thinkingLevel, modelBaseUrl: baseUrlOverride, }); @@ -419,6 +421,7 @@ async function setupHarnessRuntime( resolved, session, skills, + contextFiles, harness, provider, modelRef: auth.modelRef, @@ -506,6 +509,7 @@ export async function runInteractiveCommand( browserHandle: runtime.handle, session: runtime.session, skills: runtime.skills, + contextFiles: runtime.contextFiles, modelRef: runtime.modelRef, provider: runtime.provider, initialPrompt: initialPrompt || undefined, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b3fae70..b3a249c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -58,8 +58,10 @@ Options: --session Resume a specific session: path | partial id | latest --session-dir Override the sessions directory --no-session Don't persist this session to disk - --skill Load a skill file or directory (repeatable). - Defaults: ~/.agents/skills/, /.agents/skills/ + --skill Load an extra skill file or directory (repeatable). + Skills also load from ~/.agents/skills/, + /.agents/skills/, the pi agent dir + (~/.pi/agent/), and pi-installed packages. -ns, --no-skills Disable skill discovery entirely --debug-tui Enable TUI render diagnostics for manual repros -v, --verbose Verbose progress output to stderr diff --git a/packages/cli/src/harness-skills.ts b/packages/cli/src/harness-skills.ts index 38d8f41..531681c 100644 --- a/packages/cli/src/harness-skills.ts +++ b/packages/cli/src/harness-skills.ts @@ -1,7 +1,10 @@ import { type ExecutionEnv, loadSkills, type Skill, type SkillDiagnostic } from "@onkernel/cua-agent"; -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { + DefaultResourceLoader, + getAgentDir, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { dirname, join } from "node:path"; export interface DiscoverSkillsOptions { cwd: string; @@ -10,30 +13,110 @@ export interface DiscoverSkillsOptions { extraPaths?: string[]; /** Disable all skill discovery. */ disabled?: boolean; + /** pi agent dir to resolve installed packages from. Defaults to `getAgentDir()`. */ + agentDir?: string; +} + +export interface ContextFile { + path: string; + content: string; } export interface DiscoverSkillsResult { skills: Skill[]; - sources: string[]; + contextFiles: ContextFile[]; diagnostics: SkillDiagnostic[]; } /** - * Discover skills following the cross-agent `~/.agents/skills/` standard. + * Discover skills and context files via pi's `DefaultResourceLoader`, the same + * loader pi's own TUI uses. This resolves skills from installed pi packages + * (`pi install …` writes them under the agent dir and records them in + * settings.json) in addition to `~/.agents/skills/`, `/.agents/skills/`, + * `~/.pi/agent/skills/`, and explicit `--skill` paths. + * + * Startup never blocks on an interactive prompt: project settings start + * untrusted (no trust prompt), and `PI_OFFLINE` keeps a configured-but-not- + * installed package from triggering a network install — it is skipped instead. * - * Discovery order: explicit `--skill` paths, then `~/.agents/skills/`, - * then `/.agents/skills/`. Missing paths are skipped silently. + * pi extensions are not loaded (`noExtensions`): cua's harness drives the + * lower-level `AgentHarness` directly and cannot bind pi `AgentSession` + * extensions. */ export async function discoverCuaSkills(opts: DiscoverSkillsOptions): Promise { - if (opts.disabled) return { skills: [], sources: [], diagnostics: [] }; const extras = (opts.extraPaths ?? []).filter((p) => p && p.trim().length > 0); - const userAgentsDir = join(homedir(), ".agents", "skills"); - const projectAgentsDir = join(opts.cwd, ".agents", "skills"); - const candidates = [...extras, userAgentsDir, projectAgentsDir]; - const sources = candidates.filter((p) => existsSync(p)); - if (sources.length === 0) return { skills: [], sources: [], diagnostics: [] }; - const result = await loadSkills(opts.env, sources); - return { skills: result.skills, sources, diagnostics: result.diagnostics }; + const agentDir = opts.agentDir ?? getAgentDir(); + const settingsManager = SettingsManager.create(opts.cwd, agentDir, { projectTrusted: false }); + // Project-local `/.agents/skills` is loaded explicitly rather than via + // pi's trusted project scan. That scan only runs when the project is trusted + // (which would mean prompting the user and binding untrusted `.pi/` + // extensions); `additionalSkillPaths` loads the directory unconditionally and + // never binds extensions, so project skills work without a trust prompt. + const projectSkillDir = join(opts.cwd, ".agents", "skills"); + const additionalSkillPaths = [...extras, projectSkillDir]; + const loader = new DefaultResourceLoader({ + cwd: opts.cwd, + agentDir, + settingsManager, + additionalSkillPaths, + noSkills: opts.disabled === true, + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + }); + + const restoreOffline = forceOfflinePackageResolution(); + try { + await loader.reload(); + } finally { + restoreOffline(); + } + + const piSkills = loader.getSkills().skills; + const contextFiles = loader.getAgentsFiles().agentsFiles; + + // pi's loader resolves the skill *file paths* — the superset that includes + // package skills — but its skill objects don't carry the file body. cua's + // harness needs the full instructions, so re-read the discovered skills + // through cua-agent's `loadSkills`, which produces the `{ content }` shape + // the harness and `/skill:` expansion consume. Scan each skill's root + // directory, then keep only the files pi actually enumerated (so a skills + // root holding both a loose `.md` and a nested `SKILL.md` doesn't load the + // nested skill twice). + const discoveredPaths = new Set(piSkills.map((s) => s.filePath)); + const skillDirs = [...new Set(piSkills.map((s) => dirname(s.filePath)))]; + if (skillDirs.length === 0) { + return { skills: [], contextFiles, diagnostics: [] }; + } + const loaded = await loadSkills(opts.env, skillDirs); + const skills = dedupeByFilePath(loaded.skills.filter((s) => discoveredPaths.has(s.filePath))); + return { skills, contextFiles, diagnostics: loaded.diagnostics }; +} + +function dedupeByFilePath(skills: Skill[]): Skill[] { + const seen = new Set(); + const result: Skill[] = []; + for (const skill of skills) { + if (seen.has(skill.filePath)) continue; + seen.add(skill.filePath); + result.push(skill); + } + return result; +} + +/** + * `DefaultResourceLoader.reload()` resolves packages without an `onMissing` + * callback, which would auto-install a configured-but-missing package over the + * network. `PI_OFFLINE` makes that resolution skip missing packages instead, so + * startup can never hang on an install. Restores any prior value afterward. + */ +function forceOfflinePackageResolution(): () => void { + const previous = process.env.PI_OFFLINE; + if (previous !== undefined) return () => {}; + process.env.PI_OFFLINE = "1"; + return () => { + delete process.env.PI_OFFLINE; + }; } /** diff --git a/packages/cli/src/harness.ts b/packages/cli/src/harness.ts index 815a4d2..f68ced8 100644 --- a/packages/cli/src/harness.ts +++ b/packages/cli/src/harness.ts @@ -18,6 +18,7 @@ import { } from "@onkernel/cua-ai"; import type Kernel from "@onkernel/sdk"; import { createCodingTools } from "@earendil-works/pi-coding-agent"; +import type { ContextFile } from "./harness-skills"; /** Options for {@link buildCuaHarness}. */ export interface BuildCuaHarnessOptions { @@ -27,6 +28,8 @@ export interface BuildCuaHarnessOptions { session: Session; model: CuaModelRef; skills?: Skill[]; + /** Context files (AGENTS.md, CLAUDE.md, …) appended to the system prompt. */ + contextFiles?: ContextFile[]; thinkingLevel?: ThinkingLevel; /** Override the default coding-tools extraTools (bash/read/edit/write/grep/find/ls). */ extraTools?: CuaAgentHarnessOptions["extraTools"]; @@ -45,6 +48,7 @@ export interface BuildCuaHarnessOptions { */ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { const skills = opts.skills ?? []; + const contextFiles = opts.contextFiles ?? []; const extraTools = opts.extraTools ?? createCodingTools(opts.cwd); const model: CuaModelRef | Model = opts.modelBaseUrl ? { ...getCuaModel(opts.model), baseUrl: opts.modelBaseUrl } @@ -60,7 +64,7 @@ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { thinkingLevel: opts.thinkingLevel, systemPrompt: ({ model: activeModel, resources }) => { const runtime = resolveCuaRuntimeSpec(activeModel); - return composeSystemPrompt(runtime.defaultSystemPrompt, resources.skills ?? []); + return composeSystemPrompt(runtime.defaultSystemPrompt, resources.skills ?? [], contextFiles); }, getApiKeyAndHeaders: opts.getApiKeyAndHeaders ?? @@ -71,8 +75,19 @@ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { }); } -function composeSystemPrompt(base: string, skills: Skill[]): string { +function composeSystemPrompt(base: string, skills: Skill[], contextFiles: ContextFile[]): string { + const sections = [base.trim()]; const skillBlock = formatSkillsForSystemPrompt(skills).trim(); - if (!skillBlock) return base; - return `${base.trim()}\n\n${skillBlock}\n`; + if (skillBlock) sections.push(skillBlock); + const contextBlock = formatContextFiles(contextFiles); + if (contextBlock) sections.push(contextBlock); + return `${sections.join("\n\n")}\n`; +} + +function formatContextFiles(contextFiles: ContextFile[]): string { + const blocks = contextFiles + .filter((file) => file.content.trim().length > 0) + .map((file) => `## ${file.path}\n\n${file.content.trim()}`); + if (blocks.length === 0) return ""; + return `# Context\n\n${blocks.join("\n\n")}`; } diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts index a186a69..67111d3 100644 --- a/packages/cli/src/tui/main.ts +++ b/packages/cli/src/tui/main.ts @@ -20,9 +20,12 @@ import { TUI, TUI_KEYBINDINGS, } from "@earendil-works/pi-tui"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { homedir } from "node:os"; import type { ImageContent, Model } from "@onkernel/cua-ai"; import { captureScreenshot, type CuaBrowserHandle } from "../harness-browser"; import { resolveCuaModelRef } from "../harness-models"; +import type { ContextFile } from "../harness-skills"; import { openTuiDebugLog } from "./debug-log"; import { applyAndSummarizeImageProtocol } from "./diagnostics"; import { type AssistantBuffer, MessageList } from "./message-list"; @@ -30,7 +33,8 @@ import { ScreenshotWidget } from "./screenshot-widget"; import { buildAutocompleteProvider, parseSlashCommand } from "./slash-commands"; import { StatusLine } from "./status-line"; import { TelemetryFooter } from "./telemetry-footer"; -import { colors, editorTheme } from "./themes"; +import { colors, getEditorTheme } from "./themes"; +import { cuaVersion } from "./version"; export interface InteractiveOptions { cwd: string; @@ -38,6 +42,8 @@ export interface InteractiveOptions { browserHandle: CuaBrowserHandle; session: Session; skills?: Skill[]; + /** Loaded context files (AGENTS.md, …) shown in the `[Context]` section. */ + contextFiles?: ContextFile[]; /** CUA model ref currently active. Used for the status line and `/model` default. */ modelRef: string; provider: string; @@ -61,6 +67,9 @@ export interface InteractiveOptions { * directly via `harness.subscribe()`. */ export async function runInteractive(opts: InteractiveOptions): Promise { + // pi's `theme` singleton throws until initialized; do this before any + // component or theme helper runs. + initTheme(); // Apply image protocol override BEFORE constructing TUI components so // the Image component sees the resolved capabilities on its first render. const { summary: capsSummary, overridden } = applyAndSummarizeImageProtocol(opts.imageProtocol); @@ -94,7 +103,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise const _keybindings = new KeybindingsManager(TUI_KEYBINDINGS); void _keybindings; - const editor = new Editor(tui, editorTheme); + const editor = new Editor(tui, getEditorTheme()); editor.setAutocompleteProvider(buildAutocompleteProvider(opts.cwd, opts.skills ?? [])); const messages = new MessageList(); const screenshot = new ScreenshotWidget(); @@ -113,7 +122,9 @@ export async function runInteractive(opts: InteractiveOptions): Promise }); const header = new Container(); - header.addChild(new Text(colors.bold("cua") + colors.dim(" — kernel-cloud-browser computer-use agent"), 0, 0)); + const logo = colors.bold(colors.accent("cua")) + colors.dim(` v${cuaVersion()}`); + header.addChild(new Text(logo, 0, 0)); + header.addChild(new Text(keyHintRow(), 0, 0)); const capsHint = overridden ? colors.dim(capsSummary) : colors.dim(capsSummary + " · set CUA_IMAGE_PROTOCOL=kitty|iterm2 to force inline images"); @@ -123,8 +134,13 @@ export async function runInteractive(opts: InteractiveOptions): Promise } header.addChild(new Text("", 0, 0)); + const contextSection = buildContextSection(opts.contextFiles ?? []); const skillSection = buildSkillSection(opts.skills ?? []); tui.addChild(header); + if (contextSection) { + tui.addChild(contextSection); + tui.addChild(new Spacer(1)); + } if (skillSection) { tui.addChild(skillSection); tui.addChild(new Spacer(1)); @@ -228,14 +244,14 @@ export async function runInteractive(opts: InteractiveOptions): Promise } | undefined; const isError = !!event.isError; - let summary = isError ? colors.red("error") : colors.green("ok"); + let summary = isError ? colors.error("error") : colors.success("ok"); if (!isError && result?.content) { const imgs = result.content.filter((c) => c?.type === "image"); if (imgs.length > 0) summary += colors.dim(` · ${imgs.length} screenshot${imgs.length > 1 ? "s" : ""}`); const lastImg = imgs[imgs.length - 1]; if (lastImg?.data) screenshot.update(lastImg.data, lastImg.mimeType ?? "image/png"); } - if (isError && result?.details?.error) summary = colors.red(result.details.error); + if (isError && result?.details?.error) summary = colors.error(result.details.error); messages.addToolResult(event.toolName, !isError, summary); debug?.log("tool_execution_end", { toolName: event.toolName, @@ -511,9 +527,39 @@ async function refreshContextTokens(session: Session): Promise { return estimateContextTokens(context.messages).tokens; } +function keyHintRow(): string { + const hint = (keys: string, label: string) => colors.bold(keys) + colors.dim(` ${label}`); + return [ + hint("esc/ctrl+c", "to interrupt"), + hint("ctrl+c/ctrl+d", "to exit"), + hint("/", "for commands"), + ].join(colors.muted(" · ")); +} + +function sectionLabel(name: string): string { + return colors.heading(`[${name}]`); +} + +function buildContextSection(contextFiles: ContextFile[]): Container | undefined { + if (contextFiles.length === 0) return undefined; + const paths = contextFiles.map((file) => displayPath(file.path)).join(", "); + const container = new Container(); + container.addChild(new Text(sectionLabel("Context") + "\n" + colors.dim(` ${paths}`), 0, 0)); + return container; +} + function buildSkillSection(skills: Skill[]): Container | undefined { if (skills.length === 0) return undefined; + const names = skills + .map((s) => s.name) + .sort((a, b) => a.localeCompare(b)) + .join(", "); const container = new Container(); - container.addChild(new Text(colors.blue("[Skills]") + "\n" + skills.map((s) => s.name).join(", "), 0, 0)); + container.addChild(new Text(sectionLabel("Skills") + "\n" + colors.dim(` ${names}`), 0, 0)); return container; } + +function displayPath(path: string): string { + const home = homedir(); + return path.startsWith(home) ? `~${path.slice(home.length)}` : path; +} diff --git a/packages/cli/src/tui/message-list.ts b/packages/cli/src/tui/message-list.ts index f567942..422c84d 100644 --- a/packages/cli/src/tui/message-list.ts +++ b/packages/cli/src/tui/message-list.ts @@ -1,5 +1,5 @@ import { Container, Markdown, Text } from "@earendil-works/pi-tui"; -import { colors, markdownTheme } from "./themes"; +import { colors, getMarkdownTheme } from "./themes"; /** * Append-only chat log of user prompts, assistant text, tool-call summaries, @@ -20,20 +20,20 @@ export class MessageList extends Container { addToolCall(name: string, args: unknown): void { const summary = formatToolCall(name, args); - this.appendBlock([colors.cyan("· ") + colors.dim(name) + " " + summary]); + this.appendBlock([colors.accent("· ") + colors.dim(name) + " " + summary]); } addToolResult(name: string, ok: boolean, summary: string): void { - const icon = ok ? colors.green("✓") : colors.red("✗"); + const icon = ok ? colors.success("✓") : colors.error("✗"); this.appendBlock([` ${icon} ${colors.dim(name)} ${summary}`]); } addNotice(text: string): void { - this.appendBlock([colors.yellow("· ") + colors.dim(text)]); + this.appendBlock([colors.warning("· ") + colors.dim(text)]); } addError(text: string): void { - this.appendBlock([colors.red("error ") + text]); + this.appendBlock([colors.error("error ") + text]); } private appendBlock(lines: string[]): void { @@ -51,8 +51,8 @@ export class AssistantBuffer extends Container { constructor() { super(); - this.addChild(new Text(colors.green("assistant"), 0, 0)); - this.body = new Markdown("", 0, 0, markdownTheme); + this.addChild(new Text(colors.success("assistant"), 0, 0)); + this.body = new Markdown("", 0, 0, getMarkdownTheme()); this.addChild(this.body); } diff --git a/packages/cli/src/tui/status-line.ts b/packages/cli/src/tui/status-line.ts index d7c9720..7867f41 100644 --- a/packages/cli/src/tui/status-line.ts +++ b/packages/cli/src/tui/status-line.ts @@ -36,7 +36,7 @@ export class StatusLine extends Text { if (this.state.currentUrl) parts.push(colors.dim("url ") + truncate(this.state.currentUrl, 50)); if (this.state.tokens !== undefined) parts.push(colors.dim("tokens ") + this.state.tokens.toLocaleString()); if (this.state.cost !== undefined) parts.push(colors.dim("$") + this.state.cost.toFixed(3)); - if (this.state.working) parts.push(colors.yellow(`⏳ ${this.state.working}`)); + if (this.state.working) parts.push(colors.warning(`⏳ ${this.state.working}`)); this.setText(parts.join(sep)); } } diff --git a/packages/cli/src/tui/themes.ts b/packages/cli/src/tui/themes.ts index 23d7c40..1f648bb 100644 --- a/packages/cli/src/tui/themes.ts +++ b/packages/cli/src/tui/themes.ts @@ -1,60 +1,52 @@ -import type { - EditorTheme, - ImageTheme, - MarkdownTheme, - SelectListTheme, -} from "@earendil-works/pi-tui"; +import type { EditorTheme, ImageTheme } from "@earendil-works/pi-tui"; +import { getMarkdownTheme, getSelectListTheme, Theme } from "@earendil-works/pi-coding-agent"; -const RESET = "\x1b[0m"; +/** + * cua's TUI styling rides on pi's theme system so it matches pi's own TUI. + * `initTheme()` must run once at TUI startup (see `tui/main.ts`) before any of + * these helpers are used. + * + * pi exports the `Theme` class and the markdown/select-list theme getters, but + * not the live theme instance behind `theme.fg(...)`. That instance is published + * on a `Symbol.for` global key (pi's cross-realm contract for its own `theme` + * proxy), so we read it back here to colorize text with the active palette. + */ +const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme"); -const ansi = { - dim: (text: string) => `\x1b[2m${text}${RESET}`, - bold: (text: string) => `\x1b[1m${text}${RESET}`, - italic: (text: string) => `\x1b[3m${text}${RESET}`, - underline: (text: string) => `\x1b[4m${text}${RESET}`, - strikethrough: (text: string) => `\x1b[9m${text}${RESET}`, - cyan: (text: string) => `\x1b[36m${text}${RESET}`, - green: (text: string) => `\x1b[32m${text}${RESET}`, - yellow: (text: string) => `\x1b[33m${text}${RESET}`, - red: (text: string) => `\x1b[31m${text}${RESET}`, - gray: (text: string) => `\x1b[90m${text}${RESET}`, - blue: (text: string) => `\x1b[34m${text}${RESET}`, - lightBlue: (text: string) => `\x1b[38;2;129;162;190m${text}${RESET}`, - magenta: (text: string) => `\x1b[35m${text}${RESET}`, -}; - -export const colors = ansi; +function activeTheme(): Theme { + const instance = (globalThis as Record)[THEME_KEY]; + if (!(instance instanceof Theme)) { + throw new Error("pi theme not initialized; call initTheme() before rendering the TUI"); + } + return instance; +} -export const selectListTheme: SelectListTheme = { - selectedPrefix: (text) => ansi.cyan(text), - selectedText: (text) => ansi.cyan(text), - description: (text) => ansi.dim(text), - scrollInfo: (text) => ansi.dim(text), - noMatch: (text) => ansi.dim(text), +/** + * The small palette cua's components reach for, mapped onto pi theme colors so + * existing call sites keep working while picking up pi's palette. + */ +export const colors = { + dim: (text: string) => activeTheme().fg("dim", text), + bold: (text: string) => activeTheme().bold(text), + accent: (text: string) => activeTheme().fg("accent", text), + muted: (text: string) => activeTheme().fg("muted", text), + heading: (text: string) => activeTheme().fg("mdHeading", text), + success: (text: string) => activeTheme().fg("success", text), + error: (text: string) => activeTheme().fg("error", text), + warning: (text: string) => activeTheme().fg("warning", text), }; -export const editorTheme: EditorTheme = { - borderColor: (text) => ansi.lightBlue(text), - selectList: selectListTheme, -}; +export { getMarkdownTheme }; -export const imageTheme: ImageTheme = { - fallbackColor: (text) => ansi.dim(text), -}; +/** pi has no exported editor theme; compose one from its select-list theme. */ +export function getEditorTheme(): EditorTheme { + return { + borderColor: (text) => activeTheme().fg("borderAccent", text), + selectList: getSelectListTheme(), + }; +} -export const markdownTheme: MarkdownTheme = { - heading: (text) => ansi.bold(text), - link: (text) => ansi.cyan(text), - linkUrl: (text) => ansi.dim(text), - code: (text) => ansi.magenta(text), - codeBlock: (text) => text, - codeBlockBorder: (text) => ansi.dim(text), - quote: (text) => ansi.dim(text), - quoteBorder: (text) => ansi.dim(text), - hr: (text) => ansi.dim(text), - listBullet: (text) => ansi.cyan(text), - bold: (text) => ansi.bold(text), - italic: (text) => ansi.italic(text), - strikethrough: (text) => ansi.strikethrough(text), - underline: (text) => ansi.underline(text), +/** pi has no image theme; only the text fallback color is cua-specific. */ +export const imageTheme: ImageTheme = { + fallbackColor: (text) => activeTheme().fg("dim", text), }; diff --git a/packages/cli/src/tui/version.ts b/packages/cli/src/tui/version.ts new file mode 100644 index 0000000..438af51 --- /dev/null +++ b/packages/cli/src/tui/version.ts @@ -0,0 +1,11 @@ +/** + * cua's version, inlined by tsdown's `define` (see tsdown.config.ts) so the + * bundled bin never reads package.json from disk at runtime. When the source + * runs unbundled (e.g. tests via tsx), the define isn't applied and this falls + * back to "dev". + */ +declare const __CUA_VERSION__: string | undefined; + +export function cuaVersion(): string { + return typeof __CUA_VERSION__ === "string" ? __CUA_VERSION__ : "dev"; +} diff --git a/packages/cli/test/fixtures/tui-fixture-runner.ts b/packages/cli/test/fixtures/tui-fixture-runner.ts index 50f90ec..f2ced50 100644 --- a/packages/cli/test/fixtures/tui-fixture-runner.ts +++ b/packages/cli/test/fixtures/tui-fixture-runner.ts @@ -5,11 +5,12 @@ * scripted provider, assembles the real {@link buildCuaHarness}, and starts * the interactive TUI. */ -import { InMemorySessionRepo } from "@onkernel/cua-agent"; +import { InMemorySessionRepo, type Skill } from "@onkernel/cua-agent"; import type { CuaModelRef } from "@onkernel/cua-ai"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { buildCuaHarness } from "../../src/harness"; +import type { ContextFile } from "../../src/harness-skills"; import { runInteractive } from "../../src/tui/main"; import { createFakeKernelEnvironment } from "./fake-kernel"; import { registerScriptedProvider, type ScriptedTurn } from "./scripted-provider"; @@ -18,6 +19,8 @@ interface TuiFixture { modelRef?: string; api?: string; turns: ScriptedTurn[]; + skills?: Skill[]; + contextFiles?: ContextFile[]; } const DEFAULT_API_FOR_MODEL: Record = { @@ -42,13 +45,16 @@ async function main(): Promise { const sessionRepo = new InMemorySessionRepo(); const session = await sessionRepo.create(); const cwd = process.cwd(); + const skills = fixture.skills ?? []; + const contextFiles = fixture.contextFiles ?? []; const harness = buildCuaHarness({ cwd, client: kernel.client, browser: kernel.browser, session, model: modelRef as CuaModelRef, - skills: [], + skills, + contextFiles, extraTools: [], getApiKeyAndHeaders: async () => ({ apiKey: "fixture-key" }), }); @@ -62,7 +68,8 @@ async function main(): Promise { async close(): Promise {}, }, session, - skills: [], + skills, + contextFiles, modelRef, provider: modelRef.split(":", 1)[0] ?? "openai", skipInitialScreenshot: true, diff --git a/packages/cli/test/fixtures/tui-fixtures/resources.json b/packages/cli/test/fixtures/tui-fixtures/resources.json new file mode 100644 index 0000000..f12c336 --- /dev/null +++ b/packages/cli/test/fixtures/tui-fixtures/resources.json @@ -0,0 +1,26 @@ +{ + "modelRef": "openai:gpt-5.5", + "api": "openai-responses", + "skills": [ + { + "name": "deploy-skill", + "description": "Ship the build.", + "content": "Run the deploy steps.", + "filePath": "/tmp/skills/deploy-skill/SKILL.md" + }, + { + "name": "review-skill", + "description": "Review a diff.", + "content": "Run the review steps.", + "filePath": "/tmp/skills/review-skill/SKILL.md" + } + ], + "contextFiles": [ + { "path": "/tmp/project/AGENTS.md", "content": "Be concise." } + ], + "turns": [ + { + "steps": [{ "type": "text", "text": "fixture response" }] + } + ] +} diff --git a/packages/cli/test/harness-assembly.test.ts b/packages/cli/test/harness-assembly.test.ts index 00d8528..ff7a8ed 100644 --- a/packages/cli/test/harness-assembly.test.ts +++ b/packages/cli/test/harness-assembly.test.ts @@ -74,6 +74,33 @@ describe("buildCuaHarness", () => { expect(capturedSystemPrompt).toContain(skillBlock); }); + it("injects loaded context files into the system prompt", async () => { + provider = registerScriptedProvider("openai-responses", [ + { steps: [{ type: "text", text: "ok" }] }, + ]); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-harness-")); + const kernel = createFakeKernelEnvironment(); + const session = await new InMemorySessionRepo().create(); + const harness = buildCuaHarness({ + cwd, + client: kernel.client, + browser: kernel.browser, + session, + model: "openai:gpt-5.5", + contextFiles: [{ path: join(cwd, "AGENTS.md"), content: "Always prefer tabs over spaces." }], + extraTools: [], + getApiKeyAndHeaders: async () => ({ apiKey: "test-key" }), + }); + let capturedSystemPrompt: string | undefined; + harness.on("before_agent_start", (event) => { + capturedSystemPrompt = event.systemPrompt; + return undefined; + }); + await harness.prompt("hi"); + expect(capturedSystemPrompt).toContain("Always prefer tabs over spaces."); + expect(capturedSystemPrompt).toContain(join(cwd, "AGENTS.md")); + }); + it("delivers the first prompt with an image attached via harness.prompt({ images })", async () => { provider = registerScriptedProvider("openai-responses", [ { steps: [{ type: "text", text: "done" }] }, diff --git a/packages/cli/test/harness-skills.test.ts b/packages/cli/test/harness-skills.test.ts new file mode 100644 index 0000000..3c01e0e --- /dev/null +++ b/packages/cli/test/harness-skills.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NodeExecutionEnv } from "@onkernel/cua-agent"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverCuaSkills } from "../src/harness-skills"; + +/** + * Skill/context discovery is hermetic: pi's resource loader reads + * `$HOME/.agents/skills` and `/.agents/skills`, so each test isolates + * `HOME` and uses a fresh empty cwd plus an explicit temp `agentDir`. That way + * the only resources in scope are the fixtures the test writes. + */ +let originalHome: string | undefined; +let cwd: string; +let agentDir: string; + +beforeEach(() => { + originalHome = process.env.HOME; + const home = mkdtempSync(join(tmpdir(), "cua-home-")); + process.env.HOME = home; + cwd = mkdtempSync(join(tmpdir(), "cua-cwd-")); + agentDir = mkdtempSync(join(tmpdir(), "cua-agentdir-")); +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; +}); + +function writeSkill(dir: string, name: string, description: string, body: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n${body}\n`); +} + +function writeSettings(packages: string[]): void { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ packages }, null, 2)); +} + +describe("discoverCuaSkills", () => { + it("discovers a skill bundled in a pi-installed package", async () => { + // A local package fixture mirrors what `pi install` leaves on disk: the + // package is recorded in settings.json and its skills live under + // /skills//SKILL.md. + const pkgDir = join(agentDir, "weather-pkg"); + writeSkill(join(pkgDir, "skills", "weather"), "weather", "Check the weather forecast.", "Run the weather workflow."); + writeSettings([pkgDir]); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + const weather = result.skills.find((s) => s.name === "weather"); + expect(weather, "package skill should be discovered").toBeDefined(); + expect(weather?.content).toContain("Run the weather workflow."); + // The skill came from the package, not ~/.agents/skills (which is empty here). + expect(result.skills).toHaveLength(1); + }); + + it("loads each package skill once when a skills root mixes loose and nested skills", async () => { + // A skills root holding both a loose `.md` and a nested `/SKILL.md` + // must yield both skills exactly once (the nested skill's directory and + // the root both surface it). + const pkgDir = join(agentDir, "mixed-pkg"); + const skillsRoot = join(pkgDir, "skills"); + mkdirSync(skillsRoot, { recursive: true }); + writeFileSync(join(skillsRoot, "loose.md"), "---\nname: loose\ndescription: A loose skill.\n---\nLoose body.\n"); + writeSkill(join(skillsRoot, "nested"), "nested", "A nested skill.", "Nested body."); + writeSettings([pkgDir]); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + expect(result.skills.map((s) => s.name).sort()).toEqual(["loose", "nested"]); + }); + + it("skips a configured-but-not-installed package without throwing or hanging", async () => { + // An npm package that was never installed. Resolution must not attempt a + // network install or block; the package is skipped and discovery returns + // cleanly with no skills. + writeSettings(["npm:@example/totally-not-installed-package"]); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + expect(result.skills).toHaveLength(0); + }); + + it("discovers a project-local skill from /.agents/skills", async () => { + // Project settings stay untrusted, so pi's trusted project scan is off. + // The project skills dir must still be discovered (via additionalSkillPaths) + // without enabling untrusted `.pi/` extensions. + writeSkill(join(cwd, ".agents", "skills", "lint"), "lint", "Run the linter.", "Run the lint workflow."); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + const lint = result.skills.find((s) => s.name === "lint"); + expect(lint, "project-local skill should be discovered").toBeDefined(); + expect(lint?.content).toContain("Run the lint workflow."); + }); + + it("loads skills from an explicit --skill path", async () => { + const extraDir = mkdtempSync(join(tmpdir(), "cua-extra-skill-")); + writeSkill(join(extraDir, "deploy"), "deploy", "Ship the build.", "Run the deploy steps."); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir, extraPaths: [extraDir] }); + + expect(result.skills.map((s) => s.name)).toContain("deploy"); + }); + + it("returns no skills when disabled, but still loads context files", async () => { + const pkgDir = join(agentDir, "weather-pkg"); + writeSkill(join(pkgDir, "skills", "weather"), "weather", "Check the weather forecast.", "Run the weather workflow."); + writeSettings([pkgDir]); + writeFileSync(join(cwd, "AGENTS.md"), "# Project context\n\nBe concise.\n"); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir, disabled: true }); + + expect(result.skills).toHaveLength(0); + expect(result.contextFiles.map((f) => f.path)).toContain(join(cwd, "AGENTS.md")); + }); + + it("loads an AGENTS.md context file from the cwd", async () => { + writeFileSync(join(cwd, "AGENTS.md"), "# Project context\n\nUse two-space indentation.\n"); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + const agents = result.contextFiles.find((f) => f.path === join(cwd, "AGENTS.md")); + expect(agents, "AGENTS.md should be discovered").toBeDefined(); + expect(agents?.content).toContain("Use two-space indentation."); + }); +}); diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index 3120523..622b812 100644 --- a/packages/cli/test/tui.fixture.test.ts +++ b/packages/cli/test/tui.fixture.test.ts @@ -44,6 +44,14 @@ suite("TUI ptywright scenarios", () => { ctx.onTestFinished(() => session.close()); await waitForFixtureReady(session); + + // The pi-styled preamble renders the "cua v" logo and a + // key-hint row reflecting cua's real bindings. + const preamble = session.snapshot(); + assert.match(preamble.visible, /cua v/); + assert.match(preamble.visible, /to interrupt/); + assert.match(preamble.visible, /for commands/); + session.line("say hi"); await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS }); @@ -54,6 +62,24 @@ suite("TUI ptywright scenarios", () => { await exitFixture(session); }); + test("renders [Context] and [Skills] sections and no [Extensions]", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady } = await loadPtywrightHelpers(); + const session = spawnFixture("resources.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + + const snapshot = session.snapshot(); + assert.match(snapshot.visible, /\[Context\]/); + assert.match(snapshot.visible, /AGENTS\.md/); + assert.match(snapshot.visible, /\[Skills\]/); + assert.match(snapshot.visible, /deploy-skill/); + assert.match(snapshot.visible, /review-skill/); + assert.doesNotMatch(snapshot.visible, /\[Extensions\]/); + + await exitFixture(session); + }); + test("keeps multiline drafts left aligned", async (ctx) => { const { spawnFixture, exitFixture, waitForFixtureReady, KeyEnter } = await loadPtywrightHelpers(); const session = spawnFixture("multiline.json"); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index cb7d4f7..cc69ae1 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,5 +1,9 @@ +import { createRequire } from "node:module"; import { defineConfig } from "tsdown"; +const require = createRequire(import.meta.url); +const { version } = require("./package.json") as { version: string }; + export default defineConfig({ entry: ["src/cli.ts"], format: ["esm"], @@ -8,4 +12,7 @@ export default defineConfig({ sourcemap: false, clean: true, outExtensions: () => ({ js: ".js" }), + define: { + __CUA_VERSION__: JSON.stringify(version), + }, });