diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfe0c398..62fae615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,8 @@ jobs: cli-unit: runs-on: ubuntu-latest timeout-minutes: 15 + env: + PTYWRIGHT_ZIG_VERSION: "0.15.2" steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -57,9 +59,36 @@ jobs: node-version: 22 cache: npm - run: npm ci + - name: Cache Zig toolchain + uses: actions/cache@v4 + with: + path: .dev/tools/zig-x86_64-linux-${{ env.PTYWRIGHT_ZIG_VERSION }} + key: zig-${{ runner.os }}-${{ env.PTYWRIGHT_ZIG_VERSION }} + - name: Install Zig ${{ env.PTYWRIGHT_ZIG_VERSION }} + run: | + set -euo pipefail + ZIG_DIR=".dev/tools/zig-x86_64-linux-${PTYWRIGHT_ZIG_VERSION}" + if [ ! -x "${ZIG_DIR}/zig" ]; then + mkdir -p .dev/tools + curl -fsSL "https://ziglang.org/download/${PTYWRIGHT_ZIG_VERSION}/zig-x86_64-linux-${PTYWRIGHT_ZIG_VERSION}.tar.xz" \ + | tar -xJ -C .dev/tools + fi + "${ZIG_DIR}/zig" version + echo "${GITHUB_WORKSPACE}/${ZIG_DIR}" >> "${GITHUB_PATH}" + - name: Cache ptywright native artifacts + uses: actions/cache@v4 + with: + path: | + packages/ptywright/.cache + packages/ptywright/native/build + key: ptywright-${{ runner.os }}-${{ hashFiles('packages/ptywright/GHOSTTY_UPSTREAM', 'packages/ptywright/native/**', 'packages/ptywright/scripts/**') }} - run: npm run build --workspace @onkernel/cua-ai - run: npm run build --workspace @onkernel/cua-agent + - name: Build ptywright (native binding) + run: npm run build --workspace @onkernel/ptywright - name: CLI unit tests + env: + PTYWRIGHT_REQUIRED: "1" run: npm test --workspace @onkernel/cua-cli integration: diff --git a/package-lock.json b/package-lock.json index c5ee1f96..19740aff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2434,6 +2434,19 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.1.tgz", + "integrity": "sha512-YvZCMfSE0YDSLNklAwMY6LC6SyEgnP0zMOoioTLNnXFNdexrCexMJdee7iDJsNcFlKt7+DVLccomuURtZS1C6g==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -5423,7 +5436,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -7266,6 +7281,7 @@ "version": "0.1.0", "dependencies": { "@earendil-works/pi-coding-agent": "0.79.1", + "@earendil-works/pi-tui": "0.79.1", "@mariozechner/pi-agent-core": "0.67.6", "@mariozechner/pi-ai": "0.67.6", "@mariozechner/pi-coding-agent": "0.67.6", diff --git a/packages/cua-cli/package.json b/packages/cua-cli/package.json index 2a7b158e..11f9c127 100644 --- a/packages/cua-cli/package.json +++ b/packages/cua-cli/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@earendil-works/pi-coding-agent": "0.79.1", + "@earendil-works/pi-tui": "0.79.1", "@mariozechner/pi-agent-core": "0.67.6", "@mariozechner/pi-ai": "0.67.6", "@mariozechner/pi-coding-agent": "0.67.6", diff --git a/packages/cua-cli/src/cli-harness.ts b/packages/cua-cli/src/cli-harness.ts index c5007981..5182efae 100644 --- a/packages/cua-cli/src/cli-harness.ts +++ b/packages/cua-cli/src/cli-harness.ts @@ -173,6 +173,7 @@ export interface HarnessCliFlags { resumePicker: boolean; noSession: boolean; noSkills: boolean; + debugTui: boolean; jsonlIncludeDeltas: boolean; jsonlIncludeImages: boolean; model?: string; @@ -182,6 +183,7 @@ export interface HarnessCliFlags { maxSteps?: number; out?: string; output?: string; + imageProtocol?: string; namedSession?: string; sessionRef?: string; sessionDir?: string; @@ -490,6 +492,38 @@ export async function runPrintCommand(prompt: string, flags: HarnessCliFlags): P } } +/** Run the interactive TUI through the new harness wiring. */ +export async function runInteractiveCommand( + initialPrompt: string, + flags: HarnessCliFlags, +): Promise { + const runtime = await setupHarnessRuntime(flags); + const { runInteractive } = await import("./tui/main"); + try { + return await runInteractive({ + cwd: process.cwd(), + harness: runtime.harness, + browserHandle: runtime.handle, + session: runtime.session, + skills: runtime.skills, + modelRef: runtime.modelRef, + provider: runtime.provider, + initialPrompt: initialPrompt || undefined, + imageProtocol: flags.imageProtocol, + debugTui: flags.debugTui, + resumed: runtime.resolved?.resumed === true, + transcriptPath: runtime.resolved?.transcriptPath, + skipInitialScreenshot: runtime.resolved?.resumed === true, + }); + } finally { + try { + await runtime.handle.close(); + } catch (err) { + stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); + } + } +} + /** Run a one-shot action subcommand through the new harness wiring. */ export async function runActionCommand( action: ActionType, diff --git a/packages/cua-cli/src/cli.ts b/packages/cua-cli/src/cli.ts index aecc9176..7506deb7 100644 --- a/packages/cua-cli/src/cli.ts +++ b/packages/cua-cli/src/cli.ts @@ -1,31 +1,17 @@ #!/usr/bin/env node -import { browserSession, type BrowserSession } from "@onkernel/cua-translator"; import { stderr, stdout } from "node:process"; import { parseArgs } from "node:util"; import { type ActionType } from "./action/prompts"; import { runActionCommand, + runInteractiveCommand, runModelsSubcommand as runModelsSubcommandHarness, runPrintCommand, runSessionSubcommand as runSessionSubcommandHarness, type HarnessCliFlags, } from "./cli-harness"; import * as configMod from "./config"; -import { DEFAULT_MODEL_ID, resolveProvider } from "./models"; -import { - type NamedSessionMetadata, - recordTranscriptPath, - attachNamedSession, -} from "./named-sessions"; -import { - findLatestSession, - listSessions, - openSession, - resolveSessionPath, - type SessionInfo, -} from "./sessions"; -import { discoverStartupResources } from "./skills"; -import { runInteractive } from "./tui/main"; +import { DEFAULT_CUA_MODEL_REF } from "./harness-models"; const HELP = `cua — Kernel-cloud-browser computer-use agent @@ -46,18 +32,18 @@ Usage: Options: -p, --print Run a single prompt and exit - -m, --model Model ref (default: openai:${DEFAULT_MODEL_ID}) + -m, --model Model ref (default: ${DEFAULT_CUA_MODEL_REF}) Accepts \`provider:model\` refs or bare ids that match exactly one entry in \`cua models\`. Recommended: - openai: openai:${DEFAULT_MODEL_ID} + openai: openai:gpt-5.5 anthropic: anthropic:claude-opus-4-7 google: google:gemini-3-flash-preview tzafon: tzafon:tzafon.northstar-cua-fast yutori: yutori:n1.5-latest --thinking Thinking level: off | minimal | low | medium | high | xhigh (default: low; applies to providers that support it) - --config-profile

Config profile to load (default: from default_profile; interactive only) + --config-profile

Legacy TOML config profile to load (only used by \`cua config show\`) --profile Kernel browser profile to load --profile-no-save-changes Do not persist changes back to the profile --browser-timeout Browser inactivity timeout in seconds (default 300) @@ -221,6 +207,7 @@ function toHarnessFlags(flags: CliFlags): HarnessCliFlags { resumePicker: flags.resumePicker, noSession: flags.noSession, noSkills: flags.noSkills, + debugTui: flags.debugTui, jsonlIncludeDeltas: flags.jsonlIncludeDeltas, jsonlIncludeImages: flags.jsonlIncludeImages, model: flags.model, @@ -230,6 +217,7 @@ function toHarnessFlags(flags: CliFlags): HarnessCliFlags { maxSteps: flags.maxSteps, out: flags.out, output: flags.output, + imageProtocol: flags.imageProtocol, namedSession: flags.namedSession, sessionRef: flags.sessionRef, sessionDir: flags.sessionDir, @@ -237,156 +225,7 @@ function toHarnessFlags(flags: CliFlags): HarnessCliFlags { }; } -/** - * Load the legacy cua config and verify the keys we need for the requested - * provider. Only the interactive entry point still consumes this; the new - * non-interactive paths read API keys from env vars directly. - */ -async function loadConfigOrFail(flags: CliFlags): Promise { - const cfg = await configMod.load(flags.configProfile); - const modelId = flags.model ?? DEFAULT_MODEL_ID; - const provider = resolveProvider(modelId); - if (provider === "openai" && !cfg.openaiApiKey) { - throw new Error("missing OpenAI API key (set in profile or OPENAI_API_KEY)"); - } - if (provider === "anthropic" && !cfg.anthropicApiKey) { - throw new Error("missing Anthropic API key (set in profile or ANTHROPIC_API_KEY)"); - } - if (provider === "gemini" && !cfg.googleApiKey) { - throw new Error("missing Google API key (set in profile or GOOGLE_API_KEY / GEMINI_API_KEY)"); - } - if (provider === "tzafon" && !cfg.tzafonApiKey) { - throw new Error("missing Tzafon API key (set in profile or TZAFON_API_KEY)"); - } - if (provider === "yutori" && !cfg.yutoriApiKey) { - throw new Error("missing Yutori API key (set in profile or YUTORI_API_KEY)"); - } - if (!cfg.kernelApiKey) { - throw new Error("missing Kernel API key (set in profile or KERNEL_API_KEY)"); - } - return cfg; -} - -/** - * Resolve the session policy from CLI flags for the legacy interactive - * stack. Returns whether to attach to an existing file, create a fresh - * one, or skip persistence entirely. - */ -async function resolveSessionFlags( - flags: CliFlags, - cwd: string, - namedMeta?: NamedSessionMetadata, -): Promise<{ ephemeral: boolean; sessionPath?: string; resumed: boolean }> { - if (flags.noSession) return { ephemeral: true, resumed: false }; - const dir = flags.sessionDir; - - if (flags.sessionRef) { - const path = await resolveSessionPath(flags.sessionRef, cwd, dir); - return { ephemeral: false, sessionPath: path, resumed: true }; - } - - if (flags.continueLatest) { - const latest = await findLatestSession(cwd, dir); - if (!latest) { - stderr.write("[cua] no previous session for this cwd; starting fresh\n"); - return { ephemeral: false, resumed: false }; - } - return { ephemeral: false, sessionPath: latest.path, resumed: true }; - } - - if (flags.resumePicker) { - const sessions = await listSessions(cwd, dir); - if (sessions.length === 0) { - stderr.write("[cua] no previous sessions for this cwd; starting fresh\n"); - return { ephemeral: false, resumed: false }; - } - const picked = await pickSession(sessions); - if (!picked) return { ephemeral: false, resumed: false }; - return { ephemeral: false, sessionPath: picked.path, resumed: true }; - } - - if (namedMeta?.transcript_path) { - return { ephemeral: false, sessionPath: namedMeta.transcript_path, resumed: true }; - } - - return { ephemeral: false, resumed: false }; -} - -/** Plain-text session picker. Uses stderr for prompts so stdout stays clean. */ -async function pickSession(sessions: SessionInfo[]): Promise { - const sorted = [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime()); - stderr.write("\nResume which session?\n"); - const limit = Math.min(sorted.length, 20); - for (let i = 0; i < limit; i++) { - const s = sorted[i]!; - const name = s.name ?? truncate(s.firstMessage || "(no messages yet)", 60); - const when = formatRelative(s.modified); - stderr.write(` [${i + 1}] ${s.id.slice(0, 8)} · ${when} · ${s.messageCount} msgs · ${name}\n`); - } - if (sorted.length > limit) { - stderr.write(` (${sorted.length - limit} more not shown; use --session to select directly)\n`); - } - const { createInterface } = await import("node:readline/promises"); - const rl = createInterface({ input: process.stdin, output: process.stderr }); - try { - const answer = (await rl.question("Pick a number (or blank to skip): ")).trim(); - if (!answer) return undefined; - const n = Number(answer); - if (!Number.isFinite(n) || n < 1 || n > limit) { - stderr.write("[cua] invalid selection; starting fresh\n"); - return undefined; - } - return sorted[n - 1]; - } finally { - rl.close(); - } -} - -function truncate(text: string, max: number): string { - if (text.length <= max) return text; - return text.slice(0, max - 1) + "…"; -} - -function formatRelative(date: Date): string { - const diff = Date.now() - date.getTime(); - const min = Math.floor(diff / 60_000); - if (min < 1) return "just now"; - if (min < 60) return `${min}m ago`; - const hr = Math.floor(min / 60); - if (hr < 24) return `${hr}h ago`; - const d = Math.floor(hr / 24); - return `${d}d ago`; -} - -/** Provision a legacy-stack browser session for the interactive entry point. */ -async function provisionInteractiveBrowser( - cfg: configMod.Config, - flags: CliFlags, -): Promise<{ browser: BrowserSession; named?: NamedSessionMetadata }> { - if (flags.namedSession) { - const { browser, meta } = await attachNamedSession({ name: flags.namedSession, cfg }); - if (flags.verbose) { - stderr.write(`[cua] attached named session "${meta.name}" (browser=${browser.sessionId})\n`); - if (browser.liveUrl) stderr.write(`[cua] live view=${browser.liveUrl}\n`); - } - return { browser, named: meta }; - } - if (flags.verbose) stderr.write("[cua] provisioning Kernel browser...\n"); - const browser = await browserSession.open({ - apiKey: cfg.kernelApiKey, - baseUrl: cfg.kernelBaseUrl || undefined, - timeoutSeconds: flags.browserTimeout, - profileSelector: flags.browserProfile, - saveChanges: flags.profileSaveChanges, - }); - if (flags.verbose) { - stderr.write(`[cua] browser session=${browser.sessionId}\n`); - if (browser.liveUrl) stderr.write(`[cua] live view=${browser.liveUrl}\n`); - } - return { browser }; -} - -async function runConfigSubcommand(args: string[], profileFlag?: string): Promise { +async function runConfigSubcommand(args: string[], profile?: string): Promise { const sub = args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { stdout.write("cua config init|show\n"); @@ -397,7 +236,7 @@ async function runConfigSubcommand(args: string[], profileFlag?: string): Promis return 0; } if (sub === "show") { - const text = await configMod.show(profileFlag); + const text = await configMod.show(profile); stdout.write(text); return 0; } @@ -471,50 +310,13 @@ export async function main(argv: string[]): Promise { } try { - return await runInteractiveCli(prompt, flags); + return await runInteractiveCommand(prompt, toHarnessFlags(flags)); } catch (err) { stderr.write(`error: ${(err as Error).message}\n`); return 1; } } -async function runInteractiveCli(initialPrompt: string, flags: CliFlags): Promise { - const cfg = await loadConfigOrFail(flags); - const cwd = process.cwd(); - const provision = await provisionInteractiveBrowser(cfg, flags); - const browser = provision.browser; - const sessionPolicy = await resolveSessionFlags(flags, cwd, provision.named); - const sm = openSession({ - cwd, - sessionDir: flags.sessionDir, - sessionPath: sessionPolicy.sessionPath, - ephemeral: sessionPolicy.ephemeral, - }); - const transcriptPath = sm.getSessionFile(); - if (provision.named && transcriptPath) { - await recordTranscriptPath(provision.named.name, transcriptPath); - } - const startupResources = discoverStartupResources({ - cwd, - extraPaths: flags.skillPaths, - disabled: flags.noSkills, - }); - return await runInteractive({ - cwd, - browser, - config: cfg, - modelId: flags.model, - initialPrompt: initialPrompt || undefined, - verbose: flags.verbose, - debugTui: flags.debugTui, - imageProtocol: flags.imageProtocol, - sessionManager: sm, - resumed: sessionPolicy.resumed, - skills: startupResources.skills, - startupResources, - }); -} - main(process.argv.slice(2)).then( (code) => { process.exit(code); diff --git a/packages/cua-cli/src/tui/diagnostics.ts b/packages/cua-cli/src/tui/diagnostics.ts index fcbd1643..f648dd2b 100644 --- a/packages/cua-cli/src/tui/diagnostics.ts +++ b/packages/cua-cli/src/tui/diagnostics.ts @@ -4,7 +4,7 @@ import { detectCapabilities, getCapabilities, setCapabilities, -} from "@mariozechner/pi-tui"; +} from "@earendil-works/pi-tui"; export type ImageProtocolOverride = "kitty" | "iterm2" | "none" | "auto"; diff --git a/packages/cua-cli/src/tui/driver.ts b/packages/cua-cli/src/tui/driver.ts deleted file mode 100644 index 930a8b27..00000000 --- a/packages/cua-cli/src/tui/driver.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core"; -import type { CuaAgentHandle } from "../agent"; -import { promptWithScreenshot } from "../agent-prompt"; - -export type InteractiveDriverListener = (event: AgentEvent) => void | Promise; - -export interface InteractiveDriver { - subscribe(listener: InteractiveDriverListener): () => void; - submit(prompt: string): Promise; - abort(): void; - isStreaming(): boolean; - dispose(): Promise; -} - -export class LiveInteractiveDriver implements InteractiveDriver { - readonly agent: Agent; - private firstPrompt = true; - - constructor( - private readonly handle: CuaAgentHandle, - private readonly options: { - skipInitialScreenshot?: boolean; - } = {}, - ) { - this.agent = handle.agent; - } - - subscribe(listener: InteractiveDriverListener): () => void { - return this.handle.agent.subscribe((event) => listener(event)); - } - - async submit(prompt: string): Promise { - const skipInitialScreenshot = this.options.skipInitialScreenshot === true && this.firstPrompt; - this.firstPrompt = false; - - await promptWithScreenshot({ - agent: this.handle.agent, - translator: this.handle.translator, - prompt, - options: { skipInitialScreenshot }, - }); - } - - abort(): void { - this.handle.agent.abort(); - } - - isStreaming(): boolean { - return this.handle.agent.state.isStreaming; - } - - async dispose(): Promise { - await this.handle.dispose(); - } -} diff --git a/packages/cua-cli/src/tui/main.ts b/packages/cua-cli/src/tui/main.ts index fe781536..a186a699 100644 --- a/packages/cua-cli/src/tui/main.ts +++ b/packages/cua-cli/src/tui/main.ts @@ -1,5 +1,14 @@ import { - type Component, + type AgentHarnessEvent, + type AgentMessage, + type CuaAgentHarness, + estimateContextTokens, + formatSkillInvocation, + type Session, + type Skill, + type ThinkingLevel, +} from "@onkernel/cua-agent"; +import { Container, Editor, hyperlink, @@ -10,68 +19,59 @@ import { Text, TUI, TUI_KEYBINDINGS, -} from "@mariozechner/pi-tui"; -import type { SessionManager } from "@mariozechner/pi-coding-agent"; -import type { ResourceDiagnostic } from "@mariozechner/pi-coding-agent"; -import { anthropicSupportsCompaction } from "@onkernel/cua-anthropic"; -import type { BrowserSession } from "@onkernel/cua-translator"; -import { homedir } from "node:os"; -import { relative } from "node:path"; -import { stderr } from "node:process"; -import { createCuaAgent } from "../agent"; -import type { Config } from "../config"; -import { DEFAULT_MODEL_ID, resolveProvider } from "../models"; -import { - appendBrowserMetadata, - persistAgentEvents, - seedAgentFromSession, -} from "../sessions"; -import { expandSkillInvocation, type Skill, type StartupResources } from "../skills"; +} from "@earendil-works/pi-tui"; +import type { ImageContent, Model } from "@onkernel/cua-ai"; +import { captureScreenshot, type CuaBrowserHandle } from "../harness-browser"; +import { resolveCuaModelRef } from "../harness-models"; import { openTuiDebugLog } from "./debug-log"; import { applyAndSummarizeImageProtocol } from "./diagnostics"; -import { LiveInteractiveDriver, type InteractiveDriver } from "./driver"; import { type AssistantBuffer, MessageList } from "./message-list"; 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"; export interface InteractiveOptions { cwd: string; - browser: BrowserSession; - config: Config; - modelId?: string; + harness: CuaAgentHarness; + browserHandle: CuaBrowserHandle; + session: Session; + skills?: Skill[]; + /** CUA model ref currently active. Used for the status line and `/model` default. */ + modelRef: string; + provider: string; initialPrompt?: string; - verbose?: boolean; /** Image protocol override: kitty | iterm2 | none | auto (default: auto). */ imageProtocol?: string; - /** Optional session manager for transcript persistence. */ - sessionManager?: SessionManager; + /** Skip the first-prompt screenshot (resume case). */ + skipInitialScreenshot?: boolean; /** True when seeding the agent from a previously persisted session. */ resumed?: boolean; - /** Skills available for /skill:name expansion and system-prompt injection. */ - skills?: Skill[]; - /** Optional startup sections mirroring pi's Context/Skills inventory. */ - startupResources?: StartupResources; + /** Display path of the on-disk transcript, when one exists. */ + transcriptPath?: string; /** Enable extra TUI render diagnostics for manual repros. */ debugTui?: boolean; - /** Optional driver override used by deterministic PTY fixtures. */ - driver?: InteractiveDriver; } /** - * Run the interactive cua TUI: pi-tui differential renderer with header / - * message list / screenshot widget / editor / status line / footer hint. + * Run the interactive cua TUI: pi-tui differential renderer with header, + * message list, sticky screenshot widget, editor (autocomplete-backed slash + * commands), status line, and telemetry footer. Drives a {@link CuaAgentHarness} + * directly via `harness.subscribe()`. */ export async function runInteractive(opts: InteractiveOptions): Promise { // 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); const debug = opts.debugTui ? openTuiDebugLog() : undefined; + const initialModel = opts.harness.getModel(); + const initialThinking = opts.harness.getThinkingLevel(); + const initialContextWindow = initialModel.contextWindow ?? undefined; debug?.log("interactive_init", { - model: opts.modelId ?? DEFAULT_MODEL_ID, - browserSession: opts.browser.sessionId, - liveUrl: opts.browser.liveUrl, + model: opts.modelRef, + browserSession: opts.browserHandle.browser.session_id, + liveUrl: opts.browserHandle.browser.browser_live_view_url, capsSummary, imageProtocol: opts.imageProtocol ?? "auto", overridden, @@ -93,30 +93,22 @@ export async function runInteractive(opts: InteractiveOptions): Promise const _keybindings = new KeybindingsManager(TUI_KEYBINDINGS); void _keybindings; - const liveHandle = opts.driver - ? undefined - : createCuaAgent({ - cwd: opts.cwd, - browser: opts.browser, - config: opts.config, - modelId: opts.modelId, - sessionId: opts.browser.sessionId, - skills: opts.skills, - }); + const editor = new Editor(tui, editorTheme); + editor.setAutocompleteProvider(buildAutocompleteProvider(opts.cwd, opts.skills ?? [])); const messages = new MessageList(); const screenshot = new ScreenshotWidget(); + const liveUrl = opts.browserHandle.browser.browser_live_view_url; const status = new StatusLine({ - model: opts.modelId ?? DEFAULT_MODEL_ID, - browserSession: opts.browser.sessionId, - liveUrl: opts.browser.liveUrl, + model: modelLabel(initialModel), + browserSession: opts.browserHandle.browser.session_id, + liveUrl, }); const footer = new TelemetryFooter({ - provider: liveHandle?.provider ?? (opts.driver ? "fixture" : resolveProvider(opts.modelId ?? DEFAULT_MODEL_ID)), - model: liveHandle?.model.id ?? (opts.modelId ?? DEFAULT_MODEL_ID), - thinkingLevel: liveHandle?.thinkingLevel, - contextWindow: liveHandle?.model.contextWindow, - autoCompactEnabled: isAutoCompactEnabled(liveHandle), + provider: opts.provider, + model: modelLabel(initialModel), + thinkingLevel: initialThinking, + contextWindow: initialContextWindow, contextTokens: 0, }); @@ -126,15 +118,16 @@ export async function runInteractive(opts: InteractiveOptions): Promise ? colors.dim(capsSummary) : colors.dim(capsSummary + " · set CUA_IMAGE_PROTOCOL=kitty|iterm2 to force inline images"); header.addChild(new Text(capsHint, 0, 0)); - if (opts.browser.liveUrl) { - header.addChild(new Text(colors.dim("live ") + hyperlink(opts.browser.liveUrl, opts.browser.liveUrl), 0, 0)); + if (liveUrl) { + header.addChild(new Text(colors.dim("live ") + hyperlink(liveUrl, liveUrl), 0, 0)); } header.addChild(new Text("", 0, 0)); - const startupSections = buildStartupComponents(opts.startupResources, opts.cwd); + const skillSection = buildSkillSection(opts.skills ?? []); tui.addChild(header); - for (const section of startupSections) { - tui.addChild(section); + if (skillSection) { + tui.addChild(skillSection); + tui.addChild(new Spacer(1)); } tui.addChild(messages); tui.addChild(new Spacer(1)); @@ -152,21 +145,14 @@ export async function runInteractive(opts: InteractiveOptions): Promise }); }; - let unsubscribePersist = () => {}; - const sm = opts.sessionManager; - if (liveHandle && sm && opts.resumed) seedAgentFromSession(liveHandle.agent, sm); - if (liveHandle && sm) appendBrowserMetadata(sm, opts.browser); - if (liveHandle && sm && opts.resumed) { - messages.addNotice( - `resumed from ${sm.getSessionFile() ?? "memory"} · ${liveHandle.agent.state.messages.length} prior messages · fresh browser`, - ); + if (opts.resumed) { + const transcript = opts.transcriptPath ? ` ${opts.transcriptPath}` : ""; + messages.addNotice(`resumed${transcript} · fresh browser`); } - unsubscribePersist = liveHandle && sm ? persistAgentEvents(liveHandle.agent, sm) : () => {}; - let driver: InteractiveDriver = - opts.driver ?? new LiveInteractiveDriver(liveHandle!, { skipInitialScreenshot: opts.resumed === true }); let assistantBuffer: AssistantBuffer | undefined; let inflight = 0; + let firstPromptSent = false; let lastDisplayedError: string | undefined; const displayAgentError = (error: unknown, reason: string): void => { @@ -179,111 +165,184 @@ export async function runInteractive(opts: InteractiveOptions): Promise requestRender("agent_error", false, { reason }); }; - const unsubscribe = driver.subscribe((event) => { - if (event.type === "agent_start") { - inflight += 1; - status.update({ working: "thinking…" }); - debug?.log("agent_start", { inflight }); - requestRender("agent_start", false, { inflight }); - return; - } - if (event.type === "agent_end") { - inflight -= 1; - if (inflight <= 0) status.update({ working: undefined }); - const finalError = event.messages - .slice() - .reverse() - .find((message) => "errorMessage" in message && typeof message.errorMessage === "string"); - displayAgentError(finalError && "errorMessage" in finalError ? finalError.errorMessage : undefined, "agent_end"); - debug?.log("agent_end", { inflight }); - requestRender("agent_end", false, { inflight }); - return; - } - if (event.type === "message_start" && event.message.role === "assistant") { - assistantBuffer = messages.addAssistantStart(); - debug?.log("assistant_message_start"); - requestRender("assistant_message_start"); - return; - } - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - assistantBuffer?.append(event.assistantMessageEvent.delta); - requestRender("assistant_text_delta", false, { - deltaLength: event.assistantMessageEvent.delta.length, - }); - return; - } - if (event.type === "message_end" && event.message.role === "assistant") { - const usage = "usage" in event.message ? event.message.usage : undefined; - if (usage) { + const unsubscribe = opts.harness.subscribe((event: AgentHarnessEvent) => { + switch (event.type) { + case "agent_start": { + inflight += 1; + status.update({ working: "thinking…" }); + debug?.log("agent_start", { inflight }); + requestRender("agent_start", false, { inflight }); + return; + } + case "agent_end": { + inflight -= 1; + if (inflight <= 0) status.update({ working: undefined }); + const finalError = lastErrorMessage(event.messages); + displayAgentError(finalError, "agent_end"); + debug?.log("agent_end", { inflight }); + requestRender("agent_end", false, { inflight }); + return; + } + case "message_start": { + if (event.message.role === "assistant") { + assistantBuffer = messages.addAssistantStart(); + debug?.log("assistant_message_start"); + requestRender("assistant_message_start"); + } + return; + } + case "message_update": { + if (event.assistantMessageEvent.type === "text_delta") { + assistantBuffer?.append(event.assistantMessageEvent.delta); + requestRender("assistant_text_delta", false, { + deltaLength: event.assistantMessageEvent.delta.length, + }); + } + return; + } + case "message_end": { + if (event.message.role === "assistant") { + if (event.message.usage) { + footer.update({ contextTokens: event.message.usage.input }); + } + assistantBuffer?.end(); + assistantBuffer = undefined; + displayAgentError(event.message.errorMessage, "assistant_message_end"); + debug?.log("assistant_message_end"); + requestRender("assistant_message_end"); + } + return; + } + case "tool_execution_start": { + messages.addToolCall(event.toolName, event.args); + status.update({ working: event.toolName }); + debug?.log("tool_execution_start", { toolName: event.toolName }); + requestRender("tool_execution_start", false, { toolName: event.toolName }); + return; + } + case "tool_execution_end": { + const result = event.result as + | { + content?: Array<{ type?: string; data?: string; mimeType?: string }>; + details?: { error?: string }; + } + | undefined; + const isError = !!event.isError; + let summary = isError ? colors.red("error") : colors.green("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); + messages.addToolResult(event.toolName, !isError, summary); + debug?.log("tool_execution_end", { + toolName: event.toolName, + isError, + hasImage: !!result?.content?.some((c) => c?.type === "image"), + }); + requestRender("tool_execution_end", false, { + toolName: event.toolName, + isError, + }); + return; + } + case "model_update": { footer.update({ - contextTokens: usage.input, + provider: event.model.provider, + model: modelLabel(event.model), + contextWindow: event.model.contextWindow, }); + status.update({ model: modelLabel(event.model) }); + requestRender("model_update"); + return; } - assistantBuffer?.end(); - assistantBuffer = undefined; - displayAgentError( - "errorMessage" in event.message ? event.message.errorMessage : undefined, - "assistant_message_end", - ); - debug?.log("assistant_message_end"); - requestRender("assistant_message_end"); - return; - } - if (event.type === "tool_execution_start") { - messages.addToolCall(event.toolName, event.args); - status.update({ working: event.toolName }); - debug?.log("tool_execution_start", { toolName: event.toolName }); - requestRender("tool_execution_start", false, { toolName: event.toolName }); - return; - } - if (event.type === "tool_execution_end") { - const result = event.result as - | { - content?: Array<{ type?: string; data?: string; mimeType?: string }>; - details?: { error?: string }; - } - | undefined; - const isError = !!event.isError; - let summary = isError ? colors.red("error") : colors.green("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"); + case "thinking_level_update": { + footer.update({ thinkingLevel: event.level }); + requestRender("thinking_level_update"); + return; } - if (isError && result?.details?.error) summary = colors.red(result.details.error); - messages.addToolResult(event.toolName, !isError, summary); - debug?.log("tool_execution_end", { - toolName: event.toolName, - isError, - hasImage: !!result?.content?.some((c) => c?.type === "image"), - }); - requestRender("tool_execution_end", false, { - toolName: event.toolName, - isError, - }); - return; + case "session_compact": { + messages.addNotice(`compacted ${event.compactionEntry.tokensBefore} tokens`); + void refreshContextTokens(opts.session).then((tokens) => { + footer.update({ contextTokens: tokens }); + requestRender("session_compact"); + }); + return; + } + default: + return; } }); const pendingPrompt = opts.initialPrompt?.trim() || ""; let exitRequested = false; + const runPrompt = async (text: string): Promise => { + debug?.log("run_prompt_start", { length: text.length }); + try { + const parsed = parseSlashCommand(text); + if (parsed?.command === "model") { + await applyModelCommand(opts, footer, status, messages, parsed.argument); + return; + } + if (parsed?.command === "thinking") { + await applyThinkingCommand(opts, footer, messages, parsed.argument); + return; + } + if (parsed?.command === "compact") { + await applyCompactCommand(opts, messages); + return; + } + if (parsed?.command === "skill") { + const skill = (opts.skills ?? []).find((s) => s.name === parsed.name); + if (!skill) { + messages.addError(`unknown skill "${parsed.name}"`); + requestRender("skill_unknown"); + return; + } + messages.addNotice(`invoking /skill:${skill.name}`); + requestRender("skill_invocation"); + const skillRemainder = parsed.remainder || undefined; + const skillImages = await maybeInitialScreenshot(opts, firstPromptSent); + firstPromptSent = true; + if (skillImages) { + // `harness.skill` has no images option; fall back to `prompt` + // with the formatted skill invocation so the first turn sees + // the browser screenshot. + await opts.harness.prompt(formatSkillInvocation(skill, skillRemainder), { images: skillImages }); + } else { + await opts.harness.skill(skill.name, skillRemainder); + } + return; + } + const images = await maybeInitialScreenshot(opts, firstPromptSent); + firstPromptSent = true; + await opts.harness.prompt(text, images ? { images } : undefined); + } catch (err) { + messages.addError((err as Error).message); + debug?.log("run_prompt_error", { message: (err as Error).message }); + requestRender("run_prompt_error", false, { message: (err as Error).message }); + return; + } + debug?.log("run_prompt_end"); + }; + editor.onSubmit = (text: string) => { const trimmed = text.trim(); if (!trimmed) return; editor.setText(""); + editor.addToHistory(trimmed); messages.addUser(trimmed); debug?.log("editor_submit", { length: trimmed.length }); - const { expanded, skill } = expandSkillInvocation(trimmed, opts.skills ?? []); - if (skill) messages.addNotice(`expanding /skill:${skill.name}`); - void runPrompt(expanded); + void runPrompt(trimmed); }; const removeListener = tui.addInputListener((data) => { if (matchesKey(data, "ctrl+c")) { - if (driver.isStreaming()) { - driver.abort(); + if (inflight > 0) { + void opts.harness.abort(); messages.addNotice("aborted"); debug?.log("input_abort_stream", { key: "ctrl+c" }); requestRender("input_abort_stream", false, { key: "ctrl+c" }); @@ -299,8 +358,8 @@ export async function runInteractive(opts: InteractiveOptions): Promise debug?.log("input_exit_request", { key: "ctrl+d" }); return { consume: true }; } - if (matchesKey(data, "escape") && driver.isStreaming()) { - driver.abort(); + if (matchesKey(data, "escape") && inflight > 0) { + void opts.harness.abort(); messages.addNotice("turn aborted"); debug?.log("input_abort_stream", { key: "escape" }); requestRender("input_abort_stream", false, { key: "escape" }); @@ -316,43 +375,22 @@ export async function runInteractive(opts: InteractiveOptions): Promise fullRedraws: tui.fullRedraws, }); - const runPrompt = async (text: string): Promise => { - debug?.log("run_prompt_start", { length: text.length }); - try { - await driver.submit(text); - } catch (err) { - messages.addError((err as Error).message); - debug?.log("run_prompt_error", { message: (err as Error).message }); - requestRender("run_prompt_error", false, { message: (err as Error).message }); - return; - } - debug?.log("run_prompt_end"); - }; - try { if (pendingPrompt) { messages.addUser(pendingPrompt); - const { expanded, skill } = expandSkillInvocation(pendingPrompt, opts.skills ?? []); - if (skill) messages.addNotice(`expanding /skill:${skill.name}`); - void runPrompt(expanded); + void runPrompt(pendingPrompt); } await waitForExit( () => exitRequested, - () => driver.isStreaming(), + () => inflight > 0, ); return 0; } finally { removeListener(); unsubscribe(); - unsubscribePersist(); tui.stop(); - try { - await driver.dispose(); - } catch (err) { - stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); - } debug?.close({ fullRedraws: tui.fullRedraws, columns: terminal.columns, @@ -368,108 +406,114 @@ async function waitForExit(shouldExit: () => boolean, isBusy: () => boolean): Pr } } -function isAutoCompactEnabled( - handle: - | { - provider?: unknown; - model?: { id?: unknown }; - modelConfig?: unknown; - } - | undefined, -): boolean { - const compactThreshold = - handle?.modelConfig && - typeof handle.modelConfig === "object" && - "compactThreshold" in handle.modelConfig - ? (handle.modelConfig as { compactThreshold?: unknown }).compactThreshold - : undefined; - if (handle?.provider === "anthropic") { - const modelId = typeof handle.model?.id === "string" ? handle.model.id : ""; - return compactThreshold !== false && anthropicSupportsCompaction(modelId); - } - return typeof compactThreshold === "number" && compactThreshold > 0; +function modelLabel(model: Model | undefined): string { + if (!model) return ""; + return model.id; } -function buildStartupComponents(resources: StartupResources | undefined, cwd: string): Component[] { - if (!resources) return []; +function lastErrorMessage(messages: AgentMessage[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const m = messages[i]; + if (m && m.role === "assistant" && typeof m.errorMessage === "string") { + return m.errorMessage; + } + } + return undefined; +} - const components: Component[] = []; - const sections: Array<{ heading: string; color: (text: string) => string; body: string }> = []; +async function maybeInitialScreenshot( + opts: InteractiveOptions, + firstPromptSent: boolean, +): Promise { + if (firstPromptSent) return undefined; + if (opts.skipInitialScreenshot) return undefined; + if (await sessionHasPriorTurn(opts.session)) return undefined; + const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id); + if (!png) return undefined; + return [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }]; +} - if (resources.contextFiles.length > 0) { - sections.push({ - heading: "Context", - color: colors.blue, - body: resources.contextFiles.map((file) => formatDisplayPath(file.path, cwd)).join(", "), - }); +async function sessionHasPriorTurn(session: Session): Promise { + const entries = await session.getBranch(); + for (const entry of entries) { + if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { + return true; + } } + return false; +} - if (resources.skills.length > 0) { - sections.push({ - heading: "Skills", - color: colors.blue, - body: resources.skills.map((skill) => skill.name).join(", "), - }); +async function applyModelCommand( + opts: InteractiveOptions, + footer: TelemetryFooter, + status: StatusLine, + messages: MessageList, + argument: string, +): Promise { + const ref = argument.trim(); + if (!ref) { + messages.addError("usage: /model "); + return; } - - if (resources.skillDiagnostics.length > 0) { - sections.push({ - heading: "Skill conflicts", - color: colors.yellow, - body: formatSkillDiagnostics(resources.skillDiagnostics, cwd), + try { + const resolved = resolveCuaModelRef(ref); + await opts.harness.setModel(resolved); + const model = opts.harness.getModel(); + footer.update({ + provider: model.provider, + model: modelLabel(model), + contextWindow: model.contextWindow, }); + status.update({ model: modelLabel(model) }); + messages.addNotice(`model → ${resolved}`); + } catch (err) { + messages.addError((err as Error).message); } +} - for (const section of sections) { - components.push(new Text(section.color(`[${section.heading}]`) + `\n${section.body}`, 0, 0)); - components.push(new Spacer(1)); +async function applyThinkingCommand( + opts: InteractiveOptions, + footer: TelemetryFooter, + messages: MessageList, + argument: string, +): Promise { + const value = argument.trim().toLowerCase(); + if (!isThinkingLevel(value)) { + messages.addError("usage: /thinking "); + return; + } + try { + await opts.harness.setThinkingLevel(value); + footer.update({ thinkingLevel: value }); + messages.addNotice(`thinking → ${value}`); + } catch (err) { + messages.addError((err as Error).message); } - - return components; } -function formatSkillDiagnostics(diagnostics: ResourceDiagnostic[], cwd: string): string { - const lines: string[] = []; - const collisions = new Map(); - - for (const diagnostic of diagnostics) { - if (diagnostic.type === "collision" && diagnostic.collision) { - const current = collisions.get(diagnostic.collision.name) ?? []; - current.push(diagnostic); - collisions.set(diagnostic.collision.name, current); - continue; - } - - if (diagnostic.path) { - lines.push(` ${formatDisplayPath(diagnostic.path, cwd)}`); - lines.push(` ${diagnostic.message}`); - } else { - lines.push(` ${diagnostic.message}`); - } - } +function isThinkingLevel(value: string): value is ThinkingLevel { + return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(value); +} - for (const [name, entries] of collisions) { - const first = entries[0]?.collision; - if (!first) continue; - lines.push(` "${name}" collision:`); - lines.push(` ${colors.green("✓")} ${formatDisplayPath(first.winnerPath, cwd)}`); - for (const entry of entries) { - if (!entry.collision) continue; - lines.push(` ${colors.yellow("✗")} ${formatDisplayPath(entry.collision.loserPath, cwd)} (skipped)`); - } +async function applyCompactCommand(opts: InteractiveOptions, messages: MessageList): Promise { + messages.addNotice("compacting…"); + try { + // The `session_compact` harness event posts the final + // "compacted N tokens" notice; emitting it here too would duplicate. + await opts.harness.compact(); + } catch (err) { + messages.addError((err as Error).message); } +} - return lines.join("\n"); +async function refreshContextTokens(session: Session): Promise { + const context = await session.buildContext(); + return estimateContextTokens(context.messages).tokens; } -function formatDisplayPath(filePath: string, cwd: string): string { - const home = homedir(); - if (filePath === cwd) return "."; - if (filePath.startsWith(`${cwd}/`)) { - return relative(cwd, filePath) || "."; - } - if (filePath.startsWith(`${home}/`)) { - return `~/${relative(home, filePath)}`; - } - return filePath; +function buildSkillSection(skills: Skill[]): Container | undefined { + if (skills.length === 0) return undefined; + const container = new Container(); + container.addChild(new Text(colors.blue("[Skills]") + "\n" + skills.map((s) => s.name).join(", "), 0, 0)); + return container; } diff --git a/packages/cua-cli/src/tui/message-list.ts b/packages/cua-cli/src/tui/message-list.ts index 71bd3f7c..f5679421 100644 --- a/packages/cua-cli/src/tui/message-list.ts +++ b/packages/cua-cli/src/tui/message-list.ts @@ -1,10 +1,10 @@ -import { Container, Text } from "@mariozechner/pi-tui"; -import { colors } from "./themes"; +import { Container, Markdown, Text } from "@earendil-works/pi-tui"; +import { colors, markdownTheme } from "./themes"; /** * Append-only chat log of user prompts, assistant text, tool-call summaries, - * and inline error notes. Each entry is a Text component (or compound) so we - * delegate wrapping to pi-tui's renderer. + * and inline error notes. Assistant blocks render through pi-tui's + * {@link Markdown}; everything else uses plain styled {@link Text}. */ export class MessageList extends Container { addUser(text: string): void { @@ -19,7 +19,7 @@ export class MessageList extends Container { } addToolCall(name: string, args: unknown): void { - const summary = this.formatToolCall(name, args); + const summary = formatToolCall(name, args); this.appendBlock([colors.cyan("· ") + colors.dim(name) + " " + summary]); } @@ -42,67 +42,17 @@ export class MessageList extends Container { } this.invalidate(); } - - private formatToolCall(name: string, args: unknown): string { - if (!args || typeof args !== "object") return ""; - const obj = args as Record; - switch (name) { - case "batch_computer_actions": { - const actions = Array.isArray(obj.actions) ? obj.actions : []; - if (actions.length === 0) return "(empty)"; - const parts = (actions as Array>).slice(0, 4).map(describeAction); - const more = actions.length > 4 ? colors.dim(` +${actions.length - 4} more`) : ""; - return parts.join(colors.dim(" → ")) + more; - } - case "computer_use_extra": { - const action = typeof obj.action === "string" ? obj.action : "?"; - if (action === "goto" && typeof obj.url === "string") return `goto(${obj.url})`; - return action; - } - case "computer": { - const action = typeof obj.action === "string" ? obj.action : "?"; - const c = obj.coordinate as [number, number] | undefined; - if (Array.isArray(c) && c.length >= 2) return `${action}(${c[0]}, ${c[1]})`; - return action; - } - case "click_at": - case "hover_at": - case "scroll_at": - case "type_text_at": - case "drag_and_drop": - return colors.dim(JSON.stringify(obj)); - case "navigate": - return typeof obj.url === "string" ? `navigate(${obj.url})` : "navigate"; - case "key_combination": - return typeof obj.keys === "string" ? `key(${obj.keys})` : "key"; - case "go_back": - case "go_forward": - case "search": - case "wait_5_seconds": - case "open_web_browser": - case "scroll_document": - return ""; - case "bash": - return colors.dim(typeof obj.command === "string" ? truncate(obj.command, 80) : ""); - case "read": - case "write": - case "edit": - return colors.dim(typeof obj.path === "string" ? obj.path : ""); - default: - return colors.dim(truncate(JSON.stringify(obj), 80)); - } - } } /** Live-updating buffer for the in-flight assistant message. */ export class AssistantBuffer extends Container { private text = ""; - private readonly body: Text; + private readonly body: Markdown; constructor() { super(); this.addChild(new Text(colors.green("assistant"), 0, 0)); - this.body = new Text("", 0, 0); + this.body = new Markdown("", 0, 0, markdownTheme); this.addChild(this.body); } @@ -120,6 +70,33 @@ export class AssistantBuffer extends Container { } } +function formatToolCall(name: string, args: unknown): string { + if (!args || typeof args !== "object") return ""; + const obj = args as Record; + switch (name) { + case "computer_batch": { + const actions = Array.isArray(obj.actions) ? obj.actions : []; + if (actions.length === 0) return "(empty)"; + const parts = (actions as Array>).slice(0, 4).map(describeAction); + const more = actions.length > 4 ? colors.dim(` +${actions.length - 4} more`) : ""; + return parts.join(colors.dim(" → ")) + more; + } + case "computer_use_extra": { + const action = typeof obj.action === "string" ? obj.action : "?"; + if (action === "goto" && typeof obj.url === "string") return `goto(${obj.url})`; + return action; + } + case "bash": + return colors.dim(typeof obj.command === "string" ? truncate(obj.command, 80) : ""); + case "read": + case "write": + case "edit": + return colors.dim(typeof obj.path === "string" ? obj.path : ""); + default: + return describeAction(obj); + } +} + function truncate(text: string, max: number): string { if (text.length <= max) return text; return text.slice(0, max - 1) + "…"; @@ -158,6 +135,6 @@ function describeAction(action: Record): string { case "screenshot": return "screenshot"; default: - return t || "?"; + return t || colors.dim(truncate(JSON.stringify(action), 80)); } } diff --git a/packages/cua-cli/src/tui/screenshot-widget.ts b/packages/cua-cli/src/tui/screenshot-widget.ts index e297ac9e..34cfac6a 100644 --- a/packages/cua-cli/src/tui/screenshot-widget.ts +++ b/packages/cua-cli/src/tui/screenshot-widget.ts @@ -1,13 +1,4 @@ -import { - Container, - type Component, - allocateImageId, - getImageDimensions, - imageFallback, - renderImage, - type ImageDimensions, - type ImageTheme, -} from "@mariozechner/pi-tui"; +import { Container, Image, allocateImageId } from "@earendil-works/pi-tui"; import { imageTheme } from "./themes"; const MAX_WIDTH_CELLS = 60; @@ -18,98 +9,19 @@ const MAX_WIDTH_CELLS = 60; * a compact text card on terminals without inline image support. */ export class ScreenshotWidget extends Container { - private currentImage?: StableInlineImage; private readonly imageId = allocateImageId(); - constructor() { - super(); - } - clear(): void { this.children = []; - this.currentImage = undefined; this.invalidate(); } update(pngBase64: string, mimeType = "image/png"): void { - const dims = getImageDimensions(pngBase64, mimeType); - this.currentImage = new StableInlineImage( - pngBase64, - mimeType, - imageTheme, - { - imageId: this.imageId, - }, - dims ?? undefined, - ); - this.children = [this.currentImage]; - this.invalidate(); - } -} - -interface StableInlineImageOptions { - imageId?: number; -} - -/** - * `pi-tui`'s stock Image component prepends a cursor-up sequence before the - * Kitty payload, but it does not restore the cursor afterwards. In a - * differential renderer that keeps issuing relative cursor moves, that causes - * the terminal cursor to drift upward after image updates. This wrapper keeps - * the same logical row accounting while restoring the cursor after drawing. - */ -class StableInlineImage implements Component { - private readonly dimensions: ImageDimensions; - private imageId?: number; - private cachedLines?: string[]; - private cachedWidth?: number; - - constructor( - private readonly base64Data: string, - private readonly mimeType: string, - private readonly theme: ImageTheme, - options: StableInlineImageOptions = {}, - dimensions?: ImageDimensions, - ) { - this.imageId = options.imageId; - this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }; - } - - invalidate(): void { - this.cachedLines = undefined; - this.cachedWidth = undefined; - } - - render(width: number): string[] { - if (this.cachedLines && this.cachedWidth === width) { - return this.cachedLines; - } - - const maxWidth = Math.min(width - 2, MAX_WIDTH_CELLS); - const result = renderImage(this.base64Data, this.dimensions, { - maxWidthCells: maxWidth, + const image = new Image(pngBase64, mimeType, imageTheme, { + maxWidthCells: MAX_WIDTH_CELLS, imageId: this.imageId, }); - - let lines: string[]; - if (result) { - if (result.imageId) { - this.imageId = result.imageId; - } - lines = []; - for (let i = 0; i < result.rows - 1; i++) { - lines.push(""); - } - const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : ""; - const saveCursor = "\x1b7"; - const restoreCursor = "\x1b8"; - lines.push(saveCursor + moveUp + result.sequence + restoreCursor); - } else { - lines = [this.theme.fallbackColor(imageFallback(this.mimeType, this.dimensions))]; - } - - this.cachedLines = lines; - this.cachedWidth = width; - return lines; + this.children = [image]; + this.invalidate(); } } diff --git a/packages/cua-cli/src/tui/slash-commands.ts b/packages/cua-cli/src/tui/slash-commands.ts new file mode 100644 index 00000000..8d4ac475 --- /dev/null +++ b/packages/cua-cli/src/tui/slash-commands.ts @@ -0,0 +1,103 @@ +import { + type AutocompleteItem, + CombinedAutocompleteProvider, + type SlashCommand, +} from "@earendil-works/pi-tui"; +import type { Skill } from "@onkernel/cua-agent"; +import { listCuaModels } from "@onkernel/cua-ai"; + +/** + * Build an autocomplete provider for the TUI editor with the slash commands + * the interactive app supports: `/model`, `/thinking`, `/compact`, plus a + * `/skill:` entry per loaded skill. + * + * Model and thinking values are exposed as `getArgumentCompletions` so + * users can tab through CUA refs and reasoning levels. + */ +export function buildAutocompleteProvider( + cwd: string, + skills: Skill[], +): CombinedAutocompleteProvider { + const commands: SlashCommand[] = []; + + commands.push({ + name: "model", + description: "Switch the active CUA model", + argumentHint: "", + getArgumentCompletions: (prefix: string) => modelCompletions(prefix), + }); + + commands.push({ + name: "thinking", + description: "Set the reasoning level for future turns", + argumentHint: "", + getArgumentCompletions: (prefix: string) => thinkingCompletions(prefix), + }); + + commands.push({ + name: "compact", + description: "Summarize older turns to free context budget", + }); + + for (const skill of skills) { + commands.push({ + name: `skill:${skill.name}`, + description: skill.description, + }); + } + + return new CombinedAutocompleteProvider(commands, cwd); +} + +function modelCompletions(prefix: string): AutocompleteItem[] { + const all = listCuaModels(); + const trimmed = prefix.trim().toLowerCase(); + const filtered = trimmed + ? all.filter((m) => m.ref.toLowerCase().includes(trimmed) || m.model.toLowerCase().includes(trimmed)) + : all; + return filtered.map((m) => ({ value: m.ref, label: m.ref, description: m.name })); +} + +const THINKING_LEVELS: ReadonlyArray<{ value: string; description: string }> = [ + { value: "off", description: "Disable reasoning" }, + { value: "minimal", description: "Minimal reasoning" }, + { value: "low", description: "Low reasoning (default)" }, + { value: "medium", description: "Medium reasoning" }, + { value: "high", description: "High reasoning" }, + { value: "xhigh", description: "Maximum reasoning (selected models only)" }, +]; + +function thinkingCompletions(prefix: string): AutocompleteItem[] { + const trimmed = prefix.trim().toLowerCase(); + const filtered = trimmed ? THINKING_LEVELS.filter((t) => t.value.startsWith(trimmed)) : THINKING_LEVELS; + return filtered.map((t) => ({ value: t.value, label: t.value, description: t.description })); +} + +export type ParsedSlashCommand = + | { command: "model"; argument: string } + | { command: "thinking"; argument: string } + | { command: "compact"; argument: string } + | { command: "skill"; name: string; remainder: string }; + +/** + * Recognize the supported slash-command forms. Returns undefined when the + * text is a regular user prompt. + */ +export function parseSlashCommand(text: string): ParsedSlashCommand | undefined { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) return undefined; + const skillMatch = trimmed.match(/^\/skill:([A-Za-z0-9_\-.]+)\s*(.*)$/); + if (skillMatch) { + const [, name, rest] = skillMatch; + return { command: "skill", name: name ?? "", remainder: (rest ?? "").trim() }; + } + const builtinMatch = trimmed.match(/^\/(model|thinking|compact)\s*(.*)$/); + if (builtinMatch) { + const [, name, rest] = builtinMatch; + return { + command: name as "model" | "thinking" | "compact", + argument: (rest ?? "").trim(), + }; + } + return undefined; +} diff --git a/packages/cua-cli/src/tui/status-line.ts b/packages/cua-cli/src/tui/status-line.ts index 379e4ba0..d7c97202 100644 --- a/packages/cua-cli/src/tui/status-line.ts +++ b/packages/cua-cli/src/tui/status-line.ts @@ -1,4 +1,4 @@ -import { Text, hyperlink } from "@mariozechner/pi-tui"; +import { Text, hyperlink } from "@earendil-works/pi-tui"; import { colors } from "./themes"; export interface StatusLineState { diff --git a/packages/cua-cli/src/tui/telemetry-footer.ts b/packages/cua-cli/src/tui/telemetry-footer.ts index f3affada..aedc8f1e 100644 --- a/packages/cua-cli/src/tui/telemetry-footer.ts +++ b/packages/cua-cli/src/tui/telemetry-footer.ts @@ -1,4 +1,4 @@ -import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { colors } from "./themes"; export interface TelemetryFooterState { @@ -7,7 +7,6 @@ export interface TelemetryFooterState { thinkingLevel?: string; contextTokens?: number; contextWindow?: number; - autoCompactEnabled?: boolean; } export class TelemetryFooter implements Component { @@ -50,8 +49,7 @@ export class TelemetryFooter implements Component { const used = Math.max(0, this.state.contextTokens ?? 0); const percent = this.state.contextWindow > 0 ? ((used / this.state.contextWindow) * 100).toFixed(1) : "?"; - const auto = this.state.autoCompactEnabled ? " (auto)" : ""; - return colors.dim(`${percent}%/${formatTokens(this.state.contextWindow)}${auto}`); + return colors.dim(`${percent}%/${formatTokens(this.state.contextWindow)}`); } private renderModelInfo(): string { diff --git a/packages/cua-cli/src/tui/testing/fixture-main.ts b/packages/cua-cli/src/tui/testing/fixture-main.ts deleted file mode 100644 index 7c4cfb03..00000000 --- a/packages/cua-cli/src/tui/testing/fixture-main.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import type { BrowserSession } from "@onkernel/cua-translator"; -import type { Config } from "../../config"; -import { runInteractive } from "../main"; -import { ScriptedDriver, type ScriptedFixture } from "./scripted-driver"; - -async function main(): Promise { - const fixtureArg = process.argv[2]; - if (!fixtureArg) { - throw new Error("usage: node fixture-main.js "); - } - - const fixturePath = resolve(process.cwd(), fixtureArg); - const fixture = JSON.parse(await readFile(fixturePath, "utf8")) as ScriptedFixture; - const driver = new ScriptedDriver(fixture); - const browser = { - client: {} as BrowserSession["client"], - sessionId: fixture.browserSession ?? "fixture-session-123456", - liveUrl: fixture.liveUrl, - close: async () => {}, - } as BrowserSession; - - await runInteractive({ - cwd: process.cwd(), - browser, - config: {} as Config, - modelId: fixture.model ?? "fixture-model", - driver, - }); -} - -main().catch((error) => { - process.stderr.write(`fixture error: ${(error as Error).message}\n`); - process.exit(1); -}); diff --git a/packages/cua-cli/src/tui/testing/fixture.test.ts b/packages/cua-cli/src/tui/testing/fixture.test.ts deleted file mode 100644 index bdbe6802..00000000 --- a/packages/cua-cli/src/tui/testing/fixture.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { KeyCtrlC, KeyEnter, spawnSession } from "@onkernel/ptywright"; - -function spawnFixture() { - const fixtureMain = fileURLToPath(new URL("./fixture-main.js", import.meta.url)); - const fixtureJson = fileURLToPath(new URL("../../../src/tui/testing/fixtures/basic.json", import.meta.url)); - const cwd = fileURLToPath(new URL("../../../", import.meta.url)); - return spawnSession({ - command: process.execPath, - args: [fixtureMain, fixtureJson], - cwd, - cols: 160, - rows: 40, - }); -} - -test("fixture TUI renders submitted prompts and assistant text", async (t) => { - const session = spawnFixture(); - t.after(() => session.close()); - - await waitForFixtureReady(session); - session.line("say hi"); - await session.waitForVisible("fixture response", { timeoutMs: 10_000 }); - - const snapshot = session.snapshot(); - assert.match(snapshot.visible, /say hi/); - assert.match(snapshot.visible, /fixture response/); - - await exitFixture(session); -}); - -test("fixture TUI keeps multiline drafts left aligned", async (t) => { - const session = spawnFixture(); - t.after(() => session.close()); - - await waitForFixtureReady(session); - session.send("first line\\"); - session.press(KeyEnter); - session.send("second line"); - await session.waitForVisible("second line", { timeoutMs: 10_000 }); - - const beforeSubmit = session.snapshot(); - assert.match(beforeSubmit.visible, /^second line/m); - assert.doesNotMatch(beforeSubmit.visible, /^\s+second line/m); - - session.press(KeyEnter); - await session.waitForVisible("multiline ok", { timeoutMs: 10_000 }); - - await exitFixture(session); -}); - -test("fixture TUI can abort a running turn and recover", async (t) => { - const session = spawnFixture(); - t.after(() => session.close()); - - await waitForFixtureReady(session); - session.line("please run forever"); - await session.waitForVisible("working...", { timeoutMs: 10_000 }); - - session.press(KeyCtrlC); - await session.waitForVisible("aborted", { timeoutMs: 10_000 }); - - session.line("say hi"); - await session.waitForVisible("fixture response", { timeoutMs: 10_000 }); - - await exitFixture(session); -}); - -test("fixture TUI renders assistant errors", async (t) => { - const session = spawnFixture(); - t.after(() => session.close()); - - await waitForFixtureReady(session); - session.line("please fail"); - await session.waitForVisible("fixture provider failed", { timeoutMs: 10_000 }); - - const snapshot = session.snapshot(); - assert.match(snapshot.visible, /error fixture provider failed/); - - await exitFixture(session); -}); - -async function waitForFixtureReady(session: ReturnType) { - await session.waitForVisible("fixture/fixture-model", { timeoutMs: 10_000 }); -} - -async function exitFixture(session: ReturnType) { - try { - await session.waitForStable(100, { timeoutMs: 2_000 }); - } catch { - // If the UI is still streaming, fall back to the abort-then-exit path below. - } - - session.press(KeyCtrlC); - try { - await session.waitForExit({ timeoutMs: 1_500 }); - } catch { - try { - await session.waitForVisible("aborted", { timeoutMs: 2_000 }); - } catch { - // The first Ctrl+C may have landed during final run settlement without emitting an abort notice. - } - await session.waitForStable(100, { timeoutMs: 5_000 }); - session.press(KeyCtrlC); - await session.waitForExit({ timeoutMs: 5_000 }); - } -} diff --git a/packages/cua-cli/src/tui/testing/fixtures/basic.json b/packages/cua-cli/src/tui/testing/fixtures/basic.json deleted file mode 100644 index 9f1cc934..00000000 --- a/packages/cua-cli/src/tui/testing/fixtures/basic.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "model": "fixture-model", - "browserSession": "fixture-session-123456", - "liveUrl": "https://fixture.invalid/live", - "interactions": [ - { - "match": { "equals": "say hi" }, - "steps": [ - { "type": "assistant_text", "text": "fixture response", "chunkSize": 8, "chunkMs": 5 } - ] - }, - { - "match": { "equals": "first line\nsecond line" }, - "steps": [ - { "type": "assistant_text", "text": "multiline ok" } - ] - }, - { - "match": { "equals": "please run forever" }, - "steps": [ - { "type": "assistant_text", "text": "working...", "chunkSize": 5, "chunkMs": 10 }, - { "type": "await_abort" } - ] - }, - { - "match": { "equals": "please fail" }, - "steps": [ - { "type": "assistant_error", "message": "fixture provider failed" } - ] - } - ] -} diff --git a/packages/cua-cli/src/tui/testing/scripted-driver.ts b/packages/cua-cli/src/tui/testing/scripted-driver.ts deleted file mode 100644 index b471ec5b..00000000 --- a/packages/cua-cli/src/tui/testing/scripted-driver.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { AgentEvent } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Usage } from "@mariozechner/pi-ai"; -import type { InteractiveDriver, InteractiveDriverListener } from "../driver"; - -export interface FixturePromptMatch { - equals?: string; - regex?: string; -} - -export type FixtureStep = - | { type: "sleep"; ms: number } - | { type: "assistant_text"; text: string; chunkSize?: number; chunkMs?: number } - | { type: "assistant_error"; message: string } - | { type: "tool_start"; toolName: string; args?: unknown } - | { type: "tool_end"; toolName: string; result?: unknown; isError?: boolean } - | { type: "await_abort" }; - -export interface FixtureInteraction { - match: FixturePromptMatch; - steps: FixtureStep[]; -} - -export interface ScriptedFixture { - model?: string; - browserSession?: string; - liveUrl?: string; - interactions: FixtureInteraction[]; -} - -export class ScriptedDriver implements InteractiveDriver { - private readonly listeners = new Set(); - private currentAbort: AbortController | undefined; - private streaming = false; - private nextToolCallId = 1; - - constructor(private readonly fixture: ScriptedFixture) {} - - subscribe(listener: InteractiveDriverListener): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - async submit(prompt: string): Promise { - if (this.streaming) { - throw new Error("scripted driver is already handling a prompt"); - } - - const interaction = this.fixture.interactions.find((candidate) => matchesPrompt(candidate.match, prompt)); - if (!interaction) { - throw new Error(`no scripted fixture matched prompt: ${JSON.stringify(prompt)}`); - } - - this.streaming = true; - this.currentAbort = new AbortController(); - const signal = this.currentAbort.signal; - const startedAt = Date.now(); - let assistantText = ""; - let assistantStarted = false; - - const message = (): AssistantMessage => buildAssistantMessage(assistantText, startedAt); - const errorMessage = (text: string): AssistantMessage => buildAssistantErrorMessage(text, startedAt); - - await this.emit({ type: "agent_start" }); - try { - for (const step of interaction.steps) { - if (signal.aborted) { - break; - } - - switch (step.type) { - case "sleep": - await delay(step.ms, signal); - break; - case "assistant_text": { - if (!assistantStarted) { - assistantStarted = true; - await this.emit({ type: "message_start", message: message() }); - } - const chunkSize = Math.max(1, step.chunkSize ?? step.text.length); - for (const chunk of chunkText(step.text, chunkSize)) { - assistantText += chunk; - const partial = message(); - await this.emit({ - type: "message_update", - message: partial, - assistantMessageEvent: { - type: "text_delta", - contentIndex: 0, - delta: chunk, - partial, - }, - }); - if (step.chunkMs && step.chunkMs > 0) { - await delay(step.chunkMs, signal); - } - if (signal.aborted) { - break; - } - } - break; - } - case "assistant_error": { - assistantStarted = true; - const errMessage = errorMessage(step.message); - await this.emit({ type: "message_start", message: errMessage }); - await this.emit({ type: "message_end", message: errMessage }); - await this.emit({ type: "agent_end", messages: [errMessage] }); - return; - } - case "tool_start": { - const toolCallId = `fixture-tool-${this.nextToolCallId++}`; - await this.emit({ - type: "tool_execution_start", - toolCallId, - toolName: step.toolName, - args: step.args ?? {}, - }); - break; - } - case "tool_end": { - const toolCallId = `fixture-tool-${this.nextToolCallId++}`; - await this.emit({ - type: "tool_execution_end", - toolCallId, - toolName: step.toolName, - result: - step.result ?? { - content: [{ type: "text", text: step.isError ? "error" : "ok" }], - details: {}, - }, - isError: step.isError ?? false, - }); - break; - } - case "await_abort": - await waitForAbort(signal); - break; - } - } - } finally { - if (assistantStarted) { - await this.emit({ type: "message_end", message: message() }); - } - await this.emit({ - type: "agent_end", - messages: assistantStarted ? [message()] : [], - }); - this.streaming = false; - this.currentAbort = undefined; - } - } - - abort(): void { - this.currentAbort?.abort(); - } - - isStreaming(): boolean { - return this.streaming; - } - - async dispose(): Promise { - this.abort(); - } - - private async emit(event: AgentEvent): Promise { - for (const listener of this.listeners) { - await listener(event); - } - } -} - -function matchesPrompt(match: FixturePromptMatch, prompt: string): boolean { - if (match.equals !== undefined) { - return prompt === match.equals; - } - if (match.regex !== undefined) { - return new RegExp(match.regex, "u").test(prompt); - } - return false; -} - -function chunkText(text: string, chunkSize: number): string[] { - const chunks: string[] = []; - for (let index = 0; index < text.length; index += chunkSize) { - chunks.push(text.slice(index, index + chunkSize)); - } - return chunks.length > 0 ? chunks : [""]; -} - -async function delay(ms: number, signal: AbortSignal): Promise { - if (signal.aborted || ms <= 0) { - return; - } - await new Promise((resolve) => { - const timer = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - const cleanup = () => { - clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - }; - const onAbort = () => { - cleanup(); - resolve(); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); -} - -async function waitForAbort(signal: AbortSignal): Promise { - if (signal.aborted) { - return; - } - await new Promise((resolve) => { - signal.addEventListener("abort", () => resolve(), { once: true }); - }); -} - -const ZERO_USAGE: Usage = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, -}; - -function buildAssistantMessage(text: string, timestamp: number): AssistantMessage { - return { - role: "assistant", - content: text ? [{ type: "text", text }] : [], - api: "fixture", - provider: "fixture", - model: "fixture-model", - usage: ZERO_USAGE, - stopReason: "stop", - timestamp, - }; -} - -function buildAssistantErrorMessage(message: string, timestamp: number): AssistantMessage { - return { - ...buildAssistantMessage("", timestamp), - stopReason: "error", - errorMessage: message, - }; -} diff --git a/packages/cua-cli/src/tui/themes.ts b/packages/cua-cli/src/tui/themes.ts index 79751a1b..23d7c405 100644 --- a/packages/cua-cli/src/tui/themes.ts +++ b/packages/cua-cli/src/tui/themes.ts @@ -1,10 +1,18 @@ -import type { EditorTheme, ImageTheme, SelectListTheme } from "@mariozechner/pi-tui"; +import type { + EditorTheme, + ImageTheme, + MarkdownTheme, + SelectListTheme, +} from "@earendil-works/pi-tui"; const RESET = "\x1b[0m"; 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}`, @@ -33,3 +41,20 @@ export const editorTheme: EditorTheme = { export const imageTheme: ImageTheme = { fallbackColor: (text) => ansi.dim(text), }; + +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), +}; diff --git a/packages/cua-cli/test/fixtures/scripted-provider.ts b/packages/cua-cli/test/fixtures/scripted-provider.ts index 92ee7a27..f8def664 100644 --- a/packages/cua-cli/test/fixtures/scripted-provider.ts +++ b/packages/cua-cli/test/fixtures/scripted-provider.ts @@ -11,8 +11,9 @@ import { /** One scripted step replayed when the harness asks the provider for a turn. */ export type ScriptedStep = - | { type: "text"; text: string } + | { type: "text"; text: string; chunkSize?: number; chunkMs?: number } | { type: "tool_call"; toolName: string; args: Record; id?: string } + | { type: "wait_abort" } | { type: "error"; message: string }; export interface ScriptedTurn { @@ -51,17 +52,17 @@ export function registerScriptedProvider(api: Api, turns: ScriptedTurn[]): Scrip registerApiProvider( { api, - streamSimple: (model, context, _options?: SimpleStreamOptions) => { + streamSimple: (model, context, options?: SimpleStreamOptions) => { state.lastContext = context; const turn = turns[state.index]; state.index += 1; - return buildStream(model, turn); + return buildStream(model, turn, options?.signal); }, - stream: (model, context, _options) => { + stream: (model, context, options) => { state.lastContext = context; const turn = turns[state.index]; state.index += 1; - return buildStream(model, turn); + return buildStream(model, turn, options?.signal); }, }, sourceId, @@ -82,7 +83,7 @@ export function registerScriptedProvider(api: Api, turns: ScriptedTurn[]): Scrip }; } -function buildStream(model: Model, turn: ScriptedTurn | undefined) { +function buildStream(model: Model, turn: ScriptedTurn | undefined, signal?: AbortSignal) { const stream = createAssistantMessageEventStream(); void (async () => { const message = baseAssistantMessage(model); @@ -99,14 +100,39 @@ function buildStream(model: Model, turn: ScriptedTurn | undefined) { let hasToolCall = false; let errorStep: { message: string } | undefined; let contentIndex = 0; + let aborted = false; for (const step of turn.steps) { + if (signal?.aborted) { + aborted = true; + break; + } if (step.type === "text") { - message.content.push({ type: "text", text: step.text }); + const chunkSize = Math.max(1, step.chunkSize ?? step.text.length); + const chunkMs = step.chunkMs ?? 0; + const aggregated = { text: "" }; stream.push({ type: "text_start", contentIndex, partial: message }); - stream.push({ type: "text_delta", contentIndex, delta: step.text, partial: message }); - stream.push({ type: "text_end", contentIndex, content: step.text, partial: message }); - contentIndex += 1; + for (const chunk of chunkText(step.text, chunkSize)) { + if (signal?.aborted) { + aborted = true; + break; + } + aggregated.text += chunk; + // Append text to message progressively so the final assistant + // message reflects the full streamed value when consumers + // inspect partials. + if (message.content[contentIndex]?.type === "text") { + (message.content[contentIndex] as { text: string }).text = aggregated.text; + } else { + message.content.push({ type: "text", text: aggregated.text }); + } + stream.push({ type: "text_delta", contentIndex, delta: chunk, partial: message }); + if (chunkMs > 0) await delay(chunkMs, signal); + } + if (!aborted) { + stream.push({ type: "text_end", contentIndex, content: aggregated.text, partial: message }); + contentIndex += 1; + } } else if (step.type === "tool_call") { hasToolCall = true; const id = step.id ?? `call_${contentIndex + 1}`; @@ -124,6 +150,10 @@ function buildStream(model: Model, turn: ScriptedTurn | undefined) { partial: message, }); contentIndex += 1; + } else if (step.type === "wait_abort") { + await waitForAbort(signal); + aborted = true; + break; } else if (step.type === "error") { errorStep = { message: step.message }; break; @@ -138,6 +168,14 @@ function buildStream(model: Model, turn: ScriptedTurn | undefined) { return; } + if (aborted) { + message.stopReason = "aborted"; + message.errorMessage = "aborted"; + stream.push({ type: "error", reason: "aborted", error: message }); + stream.end(message); + return; + } + const stopReason = turn.stopReason ?? (hasToolCall ? "toolUse" : "stop"); message.stopReason = stopReason; stream.push({ type: "done", reason: stopReason, message }); @@ -146,6 +184,41 @@ function buildStream(model: Model, turn: ScriptedTurn | undefined) { return stream; } +function chunkText(text: string, chunkSize: number): string[] { + const chunks: string[] = []; + for (let index = 0; index < text.length; index += chunkSize) { + chunks.push(text.slice(index, index + chunkSize)); + } + return chunks.length > 0 ? chunks : [""]; +} + +async function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted || ms <= 0) return; + await new Promise((resolve) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + const onAbort = () => { + cleanup(); + resolve(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function waitForAbort(signal?: AbortSignal): Promise { + if (!signal) return; + if (signal.aborted) return; + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + function baseAssistantMessage(model: Model): AssistantMessage { return { role: "assistant", diff --git a/packages/cua-cli/test/fixtures/tui-fixture-runner.ts b/packages/cua-cli/test/fixtures/tui-fixture-runner.ts new file mode 100644 index 00000000..50f90ecf --- /dev/null +++ b/packages/cua-cli/test/fixtures/tui-fixture-runner.ts @@ -0,0 +1,76 @@ +/** + * Child-process entry point for ptywright-driven TUI tests. Spawned via + * `tsx` so the same source file the vitest harness imports gets type-checked + * and exercised. Receives a JSON fixture path on argv[2], registers the + * scripted provider, assembles the real {@link buildCuaHarness}, and starts + * the interactive TUI. + */ +import { InMemorySessionRepo } 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 { runInteractive } from "../../src/tui/main"; +import { createFakeKernelEnvironment } from "./fake-kernel"; +import { registerScriptedProvider, type ScriptedTurn } from "./scripted-provider"; + +interface TuiFixture { + modelRef?: string; + api?: string; + turns: ScriptedTurn[]; +} + +const DEFAULT_API_FOR_MODEL: Record = { + "openai:gpt-5.5": "openai-responses", + "anthropic:claude-opus-4-7": "anthropic-messages", + "google:gemini-3-flash-preview": "google-generative-ai", +}; + +async function main(): Promise { + const fixtureArg = process.argv[2]; + if (!fixtureArg) { + throw new Error("usage: tui-fixture-runner "); + } + const fixturePath = resolve(process.cwd(), fixtureArg); + const fixture = JSON.parse(await readFile(fixturePath, "utf8")) as TuiFixture; + + const modelRef = fixture.modelRef ?? "openai:gpt-5.5"; + const api = fixture.api ?? DEFAULT_API_FOR_MODEL[modelRef] ?? "openai-responses"; + registerScriptedProvider(api, fixture.turns); + + const kernel = createFakeKernelEnvironment(); + const sessionRepo = new InMemorySessionRepo(); + const session = await sessionRepo.create(); + const cwd = process.cwd(); + const harness = buildCuaHarness({ + cwd, + client: kernel.client, + browser: kernel.browser, + session, + model: modelRef as CuaModelRef, + skills: [], + extraTools: [], + getApiKeyAndHeaders: async () => ({ apiKey: "fixture-key" }), + }); + + const code = await runInteractive({ + cwd, + harness, + browserHandle: { + client: kernel.client, + browser: kernel.browser, + async close(): Promise {}, + }, + session, + skills: [], + modelRef, + provider: modelRef.split(":", 1)[0] ?? "openai", + skipInitialScreenshot: true, + }); + process.exit(code); +} + +main().catch((err) => { + process.stderr.write(`fixture error: ${(err as Error).message}\n`); + process.exit(1); +}); diff --git a/packages/cua-cli/test/fixtures/tui-fixtures/abort.json b/packages/cua-cli/test/fixtures/tui-fixtures/abort.json new file mode 100644 index 00000000..70b16471 --- /dev/null +++ b/packages/cua-cli/test/fixtures/tui-fixtures/abort.json @@ -0,0 +1,17 @@ +{ + "modelRef": "openai:gpt-5.5", + "api": "openai-responses", + "turns": [ + { + "steps": [ + { "type": "text", "text": "working...", "chunkSize": 5, "chunkMs": 10 }, + { "type": "wait_abort" } + ] + }, + { + "steps": [ + { "type": "text", "text": "fixture response" } + ] + } + ] +} diff --git a/packages/cua-cli/test/fixtures/tui-fixtures/error.json b/packages/cua-cli/test/fixtures/tui-fixtures/error.json new file mode 100644 index 00000000..9e572df8 --- /dev/null +++ b/packages/cua-cli/test/fixtures/tui-fixtures/error.json @@ -0,0 +1,11 @@ +{ + "modelRef": "openai:gpt-5.5", + "api": "openai-responses", + "turns": [ + { + "steps": [ + { "type": "error", "message": "fixture provider failed" } + ] + } + ] +} diff --git a/packages/cua-cli/test/fixtures/tui-fixtures/multiline.json b/packages/cua-cli/test/fixtures/tui-fixtures/multiline.json new file mode 100644 index 00000000..39c88a35 --- /dev/null +++ b/packages/cua-cli/test/fixtures/tui-fixtures/multiline.json @@ -0,0 +1,11 @@ +{ + "modelRef": "openai:gpt-5.5", + "api": "openai-responses", + "turns": [ + { + "steps": [ + { "type": "text", "text": "multiline ok" } + ] + } + ] +} diff --git a/packages/cua-cli/test/fixtures/tui-fixtures/streaming.json b/packages/cua-cli/test/fixtures/tui-fixtures/streaming.json new file mode 100644 index 00000000..96cd50cd --- /dev/null +++ b/packages/cua-cli/test/fixtures/tui-fixtures/streaming.json @@ -0,0 +1,11 @@ +{ + "modelRef": "openai:gpt-5.5", + "api": "openai-responses", + "turns": [ + { + "steps": [ + { "type": "text", "text": "fixture response", "chunkSize": 8, "chunkMs": 5 } + ] + } + ] +} diff --git a/packages/cua-cli/test/slash-commands.test.ts b/packages/cua-cli/test/slash-commands.test.ts new file mode 100644 index 00000000..84caa1ad --- /dev/null +++ b/packages/cua-cli/test/slash-commands.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { parseSlashCommand } from "../src/tui/slash-commands"; + +describe("parseSlashCommand", () => { + it("returns undefined for non-slash input", () => { + expect(parseSlashCommand("hello world")).toBeUndefined(); + expect(parseSlashCommand("")).toBeUndefined(); + }); + + it("parses /model with a provider:model argument", () => { + expect(parseSlashCommand("/model openai:gpt-5.5")).toEqual({ + command: "model", + argument: "openai:gpt-5.5", + }); + expect(parseSlashCommand("/model")).toEqual({ command: "model", argument: "" }); + }); + + it("parses /thinking with a reasoning level", () => { + expect(parseSlashCommand("/thinking high")).toEqual({ + command: "thinking", + argument: "high", + }); + }); + + it("parses /compact", () => { + expect(parseSlashCommand("/compact")).toEqual({ command: "compact", argument: "" }); + }); + + it("parses /skill: with optional remainder", () => { + expect(parseSlashCommand("/skill:hello")).toEqual({ + command: "skill", + name: "hello", + remainder: "", + }); + expect(parseSlashCommand("/skill:hello with args")).toEqual({ + command: "skill", + name: "hello", + remainder: "with args", + }); + }); + + it("returns undefined for unknown slash commands", () => { + expect(parseSlashCommand("/totally-unknown-command")).toBeUndefined(); + }); +}); diff --git a/packages/cua-cli/test/tui.fixture.test.ts b/packages/cua-cli/test/tui.fixture.test.ts new file mode 100644 index 00000000..31205237 --- /dev/null +++ b/packages/cua-cli/test/tui.fixture.test.ts @@ -0,0 +1,166 @@ +import { describe, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { strict as assert } from "node:assert"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; + +/** + * Drive the interactive TUI through ptywright with a scripted provider sitting + * below the real {@link CuaAgentHarness}. The runner script ({@link tuiRunnerPath}) + * registers the scripted provider, assembles the harness via the production + * {@link buildCuaHarness}, and starts {@link runInteractive}. Each test case + * spawns a fresh process with its own per-scenario fixture JSON so the + * scripted provider's sequential turn replay never crosses scenarios. + * + * ptywright requires a native ghostty-vt binding (built via Zig). When that + * binding is missing the suite is skipped by default; set PTYWRIGHT_REQUIRED=1 + * (CI uses this) to turn the silent skip into a failure. + */ + +const tuiRunnerPath = fileURLToPath(new URL("./fixtures/tui-fixture-runner.ts", import.meta.url)); +const require = createRequire(import.meta.url); +const tsxCliPath = require.resolve("tsx/cli"); +const fixtureDir = fileURLToPath(new URL("./fixtures/tui-fixtures/", import.meta.url)); +const cwd = fileURLToPath(new URL("../", import.meta.url)); + +const ptywrightDist = fileURLToPath(new URL("../../ptywright/dist/index.js", import.meta.url)); +const ptywrightNative = resolve(dirname(ptywrightDist), "..", "native", "build", "Release", "ptywright_native.node"); +const ptywrightAvailable = existsSync(ptywrightNative); + +if (!ptywrightAvailable && process.env.PTYWRIGHT_REQUIRED) { + throw new Error( + `ptywright native binding not found at ${ptywrightNative}; build with 'npm run build --workspace @onkernel/ptywright' or unset PTYWRIGHT_REQUIRED`, + ); +} + +const suite = ptywrightAvailable ? describe : describe.skip; +const WAIT_MS = 15_000; + +suite("TUI ptywright scenarios", () => { + test("streams assistant text into the message list", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady } = await loadPtywrightHelpers(); + const session = spawnFixture("streaming.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.line("say hi"); + await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS }); + + const snapshot = session.snapshot(); + assert.match(snapshot.visible, /say hi/); + assert.match(snapshot.visible, /fixture response/); + + await exitFixture(session); + }); + + test("keeps multiline drafts left aligned", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady, KeyEnter } = await loadPtywrightHelpers(); + const session = spawnFixture("multiline.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.send("first line\\"); + session.press(KeyEnter); + session.send("second line"); + await session.waitForVisible("second line", { timeoutMs: WAIT_MS }); + + const beforeSubmit = session.snapshot(); + assert.match(beforeSubmit.visible, /^second line/m); + assert.doesNotMatch(beforeSubmit.visible, /^\s+second line/m); + + session.press(KeyEnter); + await session.waitForVisible("multiline ok", { timeoutMs: WAIT_MS }); + + await exitFixture(session); + }); + + test("aborts a running turn with ctrl+c and recovers on the next prompt", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady, KeyCtrlC } = await loadPtywrightHelpers(); + const session = spawnFixture("abort.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.line("please run forever"); + await session.waitForVisible("working...", { timeoutMs: WAIT_MS }); + + session.press(KeyCtrlC); + await session.waitForVisible("aborted", { timeoutMs: WAIT_MS }); + + session.line("recover after abort"); + await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS }); + + await exitFixture(session); + }); + + test("renders assistant errors as error notices", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady } = await loadPtywrightHelpers(); + const session = spawnFixture("error.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.line("please fail"); + await session.waitForVisible("fixture provider failed", { timeoutMs: WAIT_MS }); + + const snapshot = session.snapshot(); + assert.match(snapshot.visible, /error fixture provider failed/); + + await exitFixture(session); + }); +}); + +/** + * Lazy-load ptywright so missing native bindings only fail this suite. The + * suite is gated behind `describe.skip` when the binding is missing, but the + * dynamic import also keeps the import graph clean for the rest of vitest. + */ +async function loadPtywrightHelpers() { + const ptywright = await import("@onkernel/ptywright"); + const { KeyCtrlC, KeyEnter, spawnSession } = ptywright; + + const spawnFixture = (fixtureFile: string) => + spawnSession({ + command: process.execPath, + args: [tsxCliPath, tuiRunnerPath, resolve(fixtureDir, fixtureFile)], + cwd, + cols: 160, + rows: 40, + env: { + ...process.env, + KERNEL_API_KEY: "fixture-key", + OPENAI_API_KEY: "fixture-key", + }, + }); + + type FixtureSession = ReturnType; + + async function waitForFixtureReady(session: FixtureSession): Promise { + await session.waitForVisible("openai/gpt-5.5", { timeoutMs: WAIT_MS }); + } + + async function exitFixture(session: FixtureSession): Promise { + try { + await session.waitForStable(100, { timeoutMs: 2_000 }); + } catch { + // fall through to abort-then-exit path + } + + session.press(KeyCtrlC); + try { + await session.waitForExit({ timeoutMs: 1_500 }); + return; + } catch { + // continue to the second-ctrl-c path + } + try { + await session.waitForVisible("aborted", { timeoutMs: 2_000 }); + } catch { + // first ctrl+c may have landed during final run settlement + } + await session.waitForStable(100, { timeoutMs: 5_000 }); + session.press(KeyCtrlC); + await session.waitForExit({ timeoutMs: 5_000 }); + } + + return { spawnFixture, exitFixture, waitForFixtureReady, KeyCtrlC, KeyEnter }; +}