From 43cdb8177a6ea548d74417bc43c96ba8281de08d Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:40:29 -0400 Subject: [PATCH 01/63] add interactive ACP agent mode for data gathering new `firecrawl agent` interactive mode that auto-detects locally installed ACP providers (Claude Code, Codex, OpenCode) and launches them to gather and structure web data into CSV, JSON, or markdown. - src/utils/acp.ts: provider detection + session persistence in ~/.firecrawl/sessions/ - src/commands/agent-interactive.ts: interactive flow with suggestions, format selection, schema confirmation - src/index.ts: wire interactive mode into agent command (no prompt = interactive, or -i flag) existing `firecrawl agent "prompt"` API mode is unchanged. --- src/commands/agent-interactive.ts | 299 ++++++++++++++++++++++++++++++ src/index.ts | 35 +++- src/utils/acp.ts | 159 ++++++++++++++++ 3 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 src/commands/agent-interactive.ts create mode 100644 src/utils/acp.ts diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts new file mode 100644 index 0000000..d693335 --- /dev/null +++ b/src/commands/agent-interactive.ts @@ -0,0 +1,299 @@ +/** + * Interactive ACP agent for data gathering. + * + * Detects locally-installed ACP providers (Claude Code, Codex, OpenCode), + * walks the user through an interactive flow to describe the data they need, + * then launches the selected provider with firecrawl CLI tools to gather, + * structure, and deliver datasets as CSV, JSON, or markdown. + */ + +import { + type ACPProvider, + createSession, + detectProviders, + getSessionDir, + listSessions, + loadSession, + updateSession, +} from '../utils/acp'; +import { type Backend, BACKENDS, launchAgent } from './experimental/backends'; +import { + FIRECRAWL_TOOLS_BLOCK, + SUBAGENT_INSTRUCTIONS, + askPermissionMode, +} from './experimental/shared'; + +// ─── Suggestions ──────────────────────────────────────────────────────────── + +const SUGGESTIONS = [ + { + name: 'Top 50 AI startups — name, funding, team size, product URL', + value: + 'Find the top 50 AI startups with their name, funding amount, team size, and product URL', + }, + { + name: 'SaaS pricing pages — company, tiers, price points, features per tier', + value: + 'Extract pricing data from major SaaS companies including company name, tier names, price points, and features per tier', + }, + { + name: 'YC W24 batch — company, founder, one-liner, industry, stage', + value: + 'Find all Y Combinator W24 batch companies with company name, founder names, one-liner description, industry, and funding stage', + }, + { + name: 'GitHub trending repos — repo, stars, language, description, author', + value: + 'Extract GitHub trending repositories with repo name, star count, primary language, description, and author', + }, +]; + +// ─── System prompt builder ────────────────────────────────────────────────── + +function buildSystemPrompt(opts: { + format: string; + sessionDir: string; +}): string { + const outputInstructions: Record = { + csv: `Write a CSV file to \`${opts.sessionDir}/output.csv\`. +- First row must be column headers. +- Use proper CSV escaping (quote fields containing commas, newlines, or quotes). +- Every row must have the same number of columns. +- Tell the user the file path and record count when done.`, + + json: `Write a JSON file to \`${opts.sessionDir}/output.json\`. +Use this structure: +\`\`\`json +{ + "metadata": { + "query": "...", + "sources": ["url1", "url2"], + "extractedAt": "ISO-8601", + "totalRecords": N + }, + "records": [ { ... }, ... ] +} +\`\`\` +Each record object must have identical keys. Tell the user the file path and record count when done.`, + + report: `Write a markdown file to \`${opts.sessionDir}/output.md\`. +- Start with a brief summary (1-2 lines). +- Render all data as a markdown table. +- If too many columns, use multiple tables grouped by category. +- Tell the user the file path and record count when done.`, + }; + + return `You are a data gathering agent powered by Firecrawl. You orchestrate parallel agents to discover sources, extract structured records, and consolidate them into clean, importable datasets. + +**CRITICAL: You are building a DATASET, not writing a report.** Think spreadsheet rows, not document sections. Every record must have the same fields. The output must be directly importable into a spreadsheet, database, or API. + +${FIRECRAWL_TOOLS_BLOCK} + +## Your Strategy + +### Phase 1: Schema Design +Before searching anything, analyze the user's request and determine: +1. What entity type are you collecting? (companies, people, products, events, etc.) +2. What fields/columns should each record have? +3. **IMPORTANT: Print the proposed schema to the user and ask them to confirm before proceeding.** Example: + "I'll collect these fields: \`name\`, \`funding\`, \`team_size\`, \`category\`, \`website\`, \`source_url\`. Look good? Or would you like to add/remove any fields?" +4. Wait for user confirmation. They may want to tweak the schema. + +### Phase 2: Source Discovery +- Use \`firecrawl search\` with multiple queries to find high-quality data sources. +- If seed URLs are provided, use \`firecrawl map\` to discover subpages. +- Identify 3-10 high-quality sources depending on request scope. + +### Phase 3: Parallel Extraction +Spawn parallel subagents — one per data source or source cluster. + +${SUBAGENT_INSTRUCTIONS} + +Each subagent should: +1. Scrape its assigned source(s) using \`firecrawl scrape \` or \`firecrawl scrape --format json\` +2. Extract records matching the confirmed schema +3. Return results as a JSON array of objects with consistent field names +4. Include \`source_url\` in every record for provenance + +### Phase 4: Consolidation +After all subagents return: +1. Merge all records into a single array +2. Deduplicate by a reasonable key (name + URL, or similar) +3. Normalize field values (consistent date formats, trim whitespace, etc.) +4. Fill missing fields with empty string (CSV) or null (JSON) — never omit fields +5. Write the final output file + +## Data Quality Rules +- Every record MUST have the exact same set of fields +- Never fabricate data — leave fields empty if not found +- Always include \`source_url\` for provenance +- Deduplicate records by a reasonable primary key +- Normalize values (consistent capitalization, date formats, etc.) + +## Output Format +${outputInstructions[opts.format] || outputInstructions.json} + +Start by analyzing the request and proposing a schema.`; +} + +// ─── Interactive flow ─────────────────────────────────────────────────────── + +export async function runInteractiveAgent(options: { + provider?: string; + session?: string; + format?: string; + yes?: boolean; +}): Promise { + const { input, select } = await import('@inquirer/prompts'); + + // ── Resume session ────────────────────────────────────────────────────── + if (options.session) { + const session = loadSession(options.session); + if (!session) { + console.error(`Session not found: ${options.session}`); + process.exit(1); + } + + console.log(`\nResuming session ${session.id}`); + console.log(` Provider: ${session.provider}`); + console.log(` Prompt: ${session.prompt}`); + console.log(` Format: ${session.format}`); + console.log(` Iterations: ${session.iterations}\n`); + + const refinement = await input({ + message: 'What would you like to refine or add?', + }); + + const backend = session.provider as Backend; + const skipPermissions = options.yes || (await askPermissionMode(backend)); + + updateSession(session.id, { + iterations: session.iterations + 1, + }); + + const systemPrompt = buildSystemPrompt({ + format: session.format, + sessionDir: getSessionDir(session.id), + }); + + const userMessage = `Continue from previous session. Original request: "${session.prompt}". Schema fields: ${session.schema.join(', ')}. Output already at: ${session.outputPath}. New instruction: ${refinement}`; + + console.log(`\nLaunching ${BACKENDS[backend].displayName}...\n`); + launchAgent(backend, systemPrompt, userMessage, skipPermissions); + return; + } + + // ── Detect providers ──────────────────────────────────────────────────── + const providers = detectProviders(); + const available = providers.filter((p) => p.available); + + if (available.length === 0) { + console.error( + '\nNo ACP providers found. Install one of:\n' + + ' npm install -g @anthropic-ai/claude-code\n' + + ' npm install -g @openai/codex\n' + + ' See https://opencode.ai/docs/cli/\n' + ); + process.exit(1); + } + + // ── Select provider ───────────────────────────────────────────────────── + let selectedProvider: ACPProvider; + + if (options.provider) { + const match = providers.find((p) => p.name === options.provider); + if (!match || !match.available) { + console.error( + `Provider "${options.provider}" is not installed. Available: ${available.map((p) => p.name).join(', ')}` + ); + process.exit(1); + } + selectedProvider = match; + } else if (available.length === 1) { + selectedProvider = available[0]; + console.log( + `\nUsing ${selectedProvider.displayName} (only provider detected)\n` + ); + } else { + const providerChoices = providers.map((p) => ({ + name: p.available + ? `● ${p.displayName} (${p.bin})` + : `○ ${p.displayName} (not installed)`, + value: p.name, + disabled: !p.available ? 'not installed' : false, + })); + + const chosen = await select({ + message: 'Which ACP provider?', + choices: providerChoices, + }); + + selectedProvider = providers.find((p) => p.name === chosen)!; + } + + // ── Gather prompt ─────────────────────────────────────────────────────── + const promptChoice = await select({ + message: 'What data do you want to gather?', + choices: [ + ...SUGGESTIONS.map((s) => ({ name: s.name, value: s.value })), + { name: 'Describe your own...', value: '__custom__' }, + ], + }); + + let prompt: string; + if (promptChoice === '__custom__') { + prompt = await input({ + message: 'Describe the data you want to collect:', + validate: (v: string) => (v.trim() ? true : 'Prompt is required'), + }); + } else { + prompt = promptChoice; + } + + // ── Seed URLs ─────────────────────────────────────────────────────────── + const urls = await input({ + message: + 'Any URLs to start from? (comma-separated, leave blank to auto-discover)', + default: '', + }); + + // ── Output format ─────────────────────────────────────────────────────── + const format = + options.format || + (await select({ + message: 'Output format?', + choices: [ + { name: 'CSV (spreadsheet-ready)', value: 'csv' }, + { name: 'JSON (structured, API-ready)', value: 'json' }, + { name: 'Markdown table (human-readable)', value: 'report' }, + ], + })); + + // ── Create session ────────────────────────────────────────────────────── + const session = createSession({ + provider: selectedProvider.name, + prompt, + schema: [], // agent will confirm schema interactively + format, + }); + + console.log(`\nSession: ${session.id}`); + console.log(`Output: ${session.outputPath}\n`); + + // ── Permission mode ───────────────────────────────────────────────────── + const backend = selectedProvider.name as Backend; + const skipPermissions = options.yes || (await askPermissionMode(backend)); + + // ── Build and launch ──────────────────────────────────────────────────── + const systemPrompt = buildSystemPrompt({ + format, + sessionDir: getSessionDir(session.id), + }); + + const parts = [`Gather data: ${prompt}`]; + if (urls.trim()) parts.push(`Start from these URLs: ${urls}`); + const userMessage = parts.join('. ') + '.'; + + console.log(`Launching ${selectedProvider.displayName}...\n`); + launchAgent(backend, systemPrompt, userMessage, skipPermissions); +} diff --git a/src/index.ts b/src/index.ts index 79fbf7a..8b84495 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { handleCrawlCommand } from './commands/crawl'; import { handleMapCommand } from './commands/map'; import { handleSearchCommand } from './commands/search'; import { handleAgentCommand } from './commands/agent'; +import { runInteractiveAgent } from './commands/agent-interactive'; import { handleBrowserLaunch, handleBrowserExecute, @@ -642,9 +643,23 @@ function createAgentCommand(): Command { const agentCmd = new Command('agent') .description('Run an AI agent to extract data from the web') .argument( - '', + '[prompt-or-job-id]', 'Natural language prompt describing data to extract, or job ID to check status' ) + .option( + '-i, --interactive', + 'Interactive mode: detect ACP providers, gather data with local agent', + false + ) + .option( + '--provider ', + 'ACP provider to use (claude, codex, opencode)' + ) + .option('--session ', 'Resume an existing interactive session') + .option( + '--format ', + 'Output format for interactive mode (csv, json, report)' + ) .option('--urls ', 'Comma-separated URLs to focus extraction on') .option( '--model ', @@ -687,7 +702,25 @@ function createAgentCommand(): Command { .option('-o, --output ', 'Output file path (default: stdout)') .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) + .option('-y, --yes', 'Auto-approve all tool permissions', false) .action(async (promptOrJobId, options) => { + // Interactive mode: no prompt, or -i flag, or --session, or --provider + const isInteractive = + !promptOrJobId || + options.interactive || + options.session || + options.provider; + + if (isInteractive) { + await runInteractiveAgent({ + provider: options.provider, + session: options.session, + format: options.format, + yes: options.yes, + }); + return; + } + // Auto-detect if it's a job ID (UUID format) const isStatusCheck = options.status || isJobId(promptOrJobId); diff --git a/src/utils/acp.ts b/src/utils/acp.ts new file mode 100644 index 0000000..6d98287 --- /dev/null +++ b/src/utils/acp.ts @@ -0,0 +1,159 @@ +/** + * ACP (Agent Client Protocol) provider detection and session management. + * + * Detects locally-installed agent providers (Claude Code, Codex, OpenCode) + * and manages persistent sessions in ~/.firecrawl/sessions/. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface ACPProvider { + name: string; + bin: string; + displayName: string; + available: boolean; +} + +export interface Session { + id: string; + provider: string; + prompt: string; + schema: string[]; + format: string; + outputPath: string; + createdAt: string; + updatedAt: string; + iterations: number; +} + +// ─── Provider registry ────────────────────────────────────────────────────── + +const PROVIDERS: Omit[] = [ + { name: 'claude', bin: 'claude', displayName: 'Claude Code' }, + { name: 'codex', bin: 'codex', displayName: 'Codex' }, + { name: 'opencode', bin: 'opencode', displayName: 'OpenCode' }, +]; + +// ─── Provider detection ───────────────────────────────────────────────────── + +function isBinAvailable(bin: string): boolean { + try { + execSync(`which ${bin}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +export function detectProviders(): ACPProvider[] { + return PROVIDERS.map((p) => ({ + ...p, + available: isBinAvailable(p.bin), + })); +} + +// ─── Sessions directory ───────────────────────────────────────────────────── + +function getFirecrawlDir(): string { + return path.join(os.homedir(), '.firecrawl'); +} + +function getSessionsDir(): string { + return path.join(getFirecrawlDir(), 'sessions'); +} + +function ensureSessionsDir(): void { + const dir = getSessionsDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +// ─── Session ID ───────────────────────────────────────────────────────────── + +function generateId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let id = ''; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +// ─── Session CRUD ─────────────────────────────────────────────────────────── + +export function createSession(opts: { + provider: string; + prompt: string; + schema: string[]; + format: string; +}): Session { + ensureSessionsDir(); + + const id = generateId(); + const sessionDir = path.join(getSessionsDir(), id); + fs.mkdirSync(sessionDir, { recursive: true }); + + const ext = + opts.format === 'csv' ? 'csv' : opts.format === 'json' ? 'json' : 'md'; + const outputPath = path.join(sessionDir, `output.${ext}`); + + const session: Session = { + id, + provider: opts.provider, + prompt: opts.prompt, + schema: opts.schema, + format: opts.format, + outputPath, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + iterations: 0, + }; + + fs.writeFileSync( + path.join(sessionDir, 'session.json'), + JSON.stringify(session, null, 2) + ); + + return session; +} + +export function loadSession(id: string): Session | null { + const sessionFile = path.join(getSessionsDir(), id, 'session.json'); + if (!fs.existsSync(sessionFile)) return null; + + try { + return JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as Session; + } catch { + return null; + } +} + +export function listSessions(): Session[] { + const dir = getSessionsDir(); + if (!fs.existsSync(dir)) return []; + + return fs + .readdirSync(dir) + .map((name) => loadSession(name)) + .filter((s): s is Session => s !== null) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export function updateSession(id: string, patch: Partial): void { + const session = loadSession(id); + if (!session) return; + + const updated = { ...session, ...patch, updatedAt: new Date().toISOString() }; + const sessionFile = path.join(getSessionsDir(), id, 'session.json'); + fs.writeFileSync(sessionFile, JSON.stringify(updated, null, 2)); +} + +export function getSessionDir(id: string): string { + return path.join(getSessionsDir(), id); +} From dec3160e9ef3574eef9cb34801faecd4f0721e4a Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:21:48 -0400 Subject: [PATCH 02/63] implement ACP client for agent data gathering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replace subprocess-spawn model with proper Agent Client Protocol integration using @agentclientprotocol/sdk. firecrawl now acts as an ACP client that can connect to any ACP-compatible agent (Claude Code, Codex, Gemini CLI, Copilot, etc.) via JSON-RPC over stdio. - src/acp/client.ts: ACP client using ClientSideConnection from SDK handles session updates, permission requests, file system ops - src/acp/registry.ts: detect installed ACP agents on PATH - src/commands/agent-interactive.ts: rewired to use ACP client instead of launchAgent() subprocess spawn - package.json: add @agentclientprotocol/sdk dependency no API keys needed — each agent handles its own auth. --- package.json | 1 + pnpm-lock.yaml | 12 ++ src/acp/client.ts | 229 ++++++++++++++++++++++++++++++ src/acp/registry.ts | 40 ++++++ src/commands/agent-interactive.ts | 150 ++++++++++++------- 5 files changed, 383 insertions(+), 49 deletions(-) create mode 100644 src/acp/client.ts create mode 100644 src/acp/registry.ts diff --git a/package.json b/package.json index 24cf09b..d7157e8 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "vitest": "^4.0.0" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.17.0", "@inquirer/prompts": "^8.2.1", "@mendable/firecrawl-js": "4.17.0", "commander": "^14.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8658d6f..8438349 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.17.0 + version: 0.17.0(zod@3.25.76) '@inquirer/prompts': specifier: ^8.2.1 version: 8.2.1(@types/node@20.19.27) @@ -39,6 +42,11 @@ importers: packages: + '@agentclientprotocol/sdk@0.17.0': + resolution: {integrity: sha512-inBMYAEd9t4E+ULZK2os9kmLG5jbPvMLbPvY71XDDem1YteW/uDwkahg6OwsGR3tvvgVhYbRJ9mJCp2VXqG4xQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -1060,6 +1068,10 @@ packages: snapshots: + '@agentclientprotocol/sdk@0.17.0(zod@3.25.76)': + dependencies: + zod: 3.25.76 + '@esbuild/aix-ppc64@0.27.2': optional: true diff --git a/src/acp/client.ts b/src/acp/client.ts new file mode 100644 index 0000000..003b298 --- /dev/null +++ b/src/acp/client.ts @@ -0,0 +1,229 @@ +/** + * Firecrawl ACP Client — connects to any ACP-compatible agent + * (Claude Code, Codex, Gemini CLI, etc.) via the Agent Client Protocol. + * + * Spawns the agent as a subprocess and communicates via JSON-RPC over stdio. + * Uses the official @agentclientprotocol/sdk. + */ + +import { spawn, type ChildProcess } from 'child_process'; +import { Writable, Readable } from 'stream'; +import * as acp from '@agentclientprotocol/sdk'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface ACPClientCallbacks { + onText?: (text: string) => void; + onToolCall?: (title: string, status: string) => void; + onToolCallUpdate?: (toolCallId: string, status: string) => void; + onPlan?: (entries: Array<{ content: string; status: string }>) => void; + onPermissionRequest?: ( + title: string, + options: Array<{ name: string; optionId: string }> + ) => Promise; // returns optionId +} + +// ─── Client implementation ────────────────────────────────────────────────── + +class FirecrawlClient implements acp.Client { + private callbacks: ACPClientCallbacks; + + constructor(callbacks: ACPClientCallbacks) { + this.callbacks = callbacks; + } + + async requestPermission( + params: acp.RequestPermissionRequest + ): Promise { + // Auto-approve by selecting the first "allow" option + const allowOption = params.options.find( + (o) => o.kind === 'allow_once' || o.kind === 'allow_always' + ); + if (allowOption) { + return { + outcome: { outcome: 'selected', optionId: allowOption.optionId }, + }; + } + + // If custom handler provided, let them choose + if (this.callbacks.onPermissionRequest) { + const optionId = await this.callbacks.onPermissionRequest( + params.toolCall.title ?? 'Unknown tool', + params.options.map((o) => ({ name: o.name, optionId: o.optionId })) + ); + return { outcome: { outcome: 'selected', optionId } }; + } + + // Fallback: select first option + return { + outcome: { outcome: 'selected', optionId: params.options[0].optionId }, + }; + } + + async sessionUpdate(params: acp.SessionNotification): Promise { + const update = params.update; + + switch (update.sessionUpdate) { + case 'agent_message_chunk': + if ( + 'content' in update && + update.content.type === 'text' && + this.callbacks.onText + ) { + this.callbacks.onText(update.content.text); + } + break; + + case 'tool_call': + if (this.callbacks.onToolCall) { + this.callbacks.onToolCall( + update.title ?? 'tool', + update.status ?? 'pending' + ); + } + break; + + case 'tool_call_update': + if (this.callbacks.onToolCallUpdate) { + this.callbacks.onToolCallUpdate( + update.toolCallId, + update.status ?? 'unknown' + ); + } + break; + + case 'plan': + if (this.callbacks.onPlan) { + this.callbacks.onPlan( + update.entries.map((e: { content: string; status: string }) => ({ + content: e.content, + status: e.status, + })) + ); + } + break; + + default: + break; + } + } + + async writeTextFile( + params: acp.WriteTextFileRequest + ): Promise { + const fs = await import('fs'); + const path = await import('path'); + const dir = path.dirname(params.path); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(params.path, params.content, 'utf-8'); + return {}; + } + + async readTextFile( + params: acp.ReadTextFileRequest + ): Promise { + const fs = await import('fs'); + const content = fs.readFileSync(params.path, 'utf-8'); + return { content }; + } +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +export async function connectToAgent(opts: { + bin: string; + args?: string[]; + cwd?: string; + systemPrompt?: string; + callbacks: ACPClientCallbacks; +}): Promise<{ + connection: acp.ClientSideConnection; + sessionId: string; + process: ChildProcess; + prompt: (text: string) => Promise; + cancel: () => Promise; + close: () => void; +}> { + // Spawn agent subprocess + const agentProcess = spawn(opts.bin, opts.args ?? [], { + stdio: ['pipe', 'pipe', 'inherit'], + cwd: opts.cwd ?? process.cwd(), + }); + + // Handle spawn errors + const spawnError = new Promise((_, reject) => { + agentProcess.on('error', (err) => { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + reject(new Error(`Agent "${opts.bin}" not found. Is it installed?`)); + } else { + reject(err); + } + }); + }); + + // Create ACP stream from stdio + const input = Writable.toWeb( + agentProcess.stdin! + ) as WritableStream; + const output = Readable.toWeb( + agentProcess.stdout! + ) as ReadableStream; + const stream = acp.ndJsonStream(input, output); + + // Create client and connection + const client = new FirecrawlClient(opts.callbacks); + const connection = new acp.ClientSideConnection((_agent) => client, stream); + + // Initialize (race with spawn error) + await Promise.race([ + connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + terminal: true, + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }), + spawnError, + ]); + + // Create session + const sessionResult = await connection.newSession({ + cwd: opts.cwd ?? process.cwd(), + mcpServers: [], + }); + + const sessionId = sessionResult.sessionId; + + // Store system prompt to prepend to first message + const systemContext = opts.systemPrompt; + + return { + connection, + sessionId, + process: agentProcess, + + async prompt(text: string) { + // Prepend system prompt as context in the first message + const fullText = systemContext + ? `\n${systemContext}\n\n\n${text}` + : text; + return connection.prompt({ + sessionId, + prompt: [{ type: 'text', text: fullText }], + }); + }, + + async cancel() { + await connection.cancel({ sessionId }); + }, + + close() { + agentProcess.kill(); + }, + }; +} diff --git a/src/acp/registry.ts b/src/acp/registry.ts new file mode 100644 index 0000000..6293826 --- /dev/null +++ b/src/acp/registry.ts @@ -0,0 +1,40 @@ +/** + * ACP agent registry — detect installed ACP-compatible agents. + */ + +import { execSync } from 'child_process'; + +export interface ACPAgent { + name: string; + bin: string; + displayName: string; + available: boolean; +} + +const KNOWN_AGENTS: Omit[] = [ + { name: 'claude', bin: 'claude', displayName: 'Claude Code' }, + { name: 'codex', bin: 'codex', displayName: 'Codex' }, + { name: 'gemini', bin: 'gemini', displayName: 'Gemini CLI' }, + { name: 'opencode', bin: 'opencode', displayName: 'OpenCode' }, + { name: 'goose', bin: 'goose', displayName: 'Goose' }, + { name: 'kimi', bin: 'kimi', displayName: 'Kimi CLI' }, + { name: 'augment', bin: 'augment', displayName: 'Augment Code' }, + { name: 'cursor', bin: 'cursor', displayName: 'Cursor' }, + { name: 'copilot', bin: 'github-copilot', displayName: 'GitHub Copilot' }, +]; + +function isBinAvailable(bin: string): boolean { + try { + execSync(`which ${bin}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +export function detectAgents(): ACPAgent[] { + return KNOWN_AGENTS.map((a) => ({ + ...a, + available: isBinAvailable(a.bin), + })); +} diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index d693335..1e4a396 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -1,26 +1,23 @@ /** * Interactive ACP agent for data gathering. * - * Detects locally-installed ACP providers (Claude Code, Codex, OpenCode), - * walks the user through an interactive flow to describe the data they need, - * then launches the selected provider with firecrawl CLI tools to gather, + * Detects locally-installed ACP-compatible agents (Claude Code, Codex, + * Gemini CLI, etc.), walks the user through an interactive flow to describe + * the data they need, then connects to the selected agent via ACP to gather, * structure, and deliver datasets as CSV, JSON, or markdown. */ +import { type ACPAgent, detectAgents } from '../acp/registry'; +import { connectToAgent } from '../acp/client'; import { - type ACPProvider, createSession, - detectProviders, getSessionDir, - listSessions, loadSession, updateSession, } from '../utils/acp'; -import { type Backend, BACKENDS, launchAgent } from './experimental/backends'; import { FIRECRAWL_TOOLS_BLOCK, SUBAGENT_INSTRUCTIONS, - askPermissionMode, } from './experimental/shared'; // ─── Suggestions ──────────────────────────────────────────────────────────── @@ -83,7 +80,7 @@ Each record object must have identical keys. Tell the user the file path and rec - Tell the user the file path and record count when done.`, }; - return `You are a data gathering agent powered by Firecrawl. You orchestrate parallel agents to discover sources, extract structured records, and consolidate them into clean, importable datasets. + return `You are a data gathering agent powered by Firecrawl. You discover sources, extract structured records, and consolidate them into clean, importable datasets. **CRITICAL: You are building a DATASET, not writing a report.** Think spreadsheet rows, not document sections. Every record must have the same fields. The output must be directly importable into a spreadsheet, database, or API. @@ -164,9 +161,6 @@ export async function runInteractiveAgent(options: { message: 'What would you like to refine or add?', }); - const backend = session.provider as Backend; - const skipPermissions = options.yes || (await askPermissionMode(backend)); - updateSession(session.id, { iterations: session.iterations + 1, }); @@ -178,57 +172,74 @@ export async function runInteractiveAgent(options: { const userMessage = `Continue from previous session. Original request: "${session.prompt}". Schema fields: ${session.schema.join(', ')}. Output already at: ${session.outputPath}. New instruction: ${refinement}`; - console.log(`\nLaunching ${BACKENDS[backend].displayName}...\n`); - launchAgent(backend, systemPrompt, userMessage, skipPermissions); + console.log(`\nConnecting to ${session.provider} via ACP...\n`); + + const agent = await connectToAgent({ + bin: session.provider, + systemPrompt, + callbacks: { + onText: (text) => process.stdout.write(text), + onToolCall: (title, status) => { + if (status === 'pending') { + process.stderr.write(`\n⟡ ${title}...\n`); + } + }, + }, + }); + + try { + await agent.prompt(userMessage); + } finally { + agent.close(); + } return; } - // ── Detect providers ──────────────────────────────────────────────────── - const providers = detectProviders(); - const available = providers.filter((p) => p.available); + // ── Detect agents ─────────────────────────────────────────────────────── + const agents = detectAgents(); + const available = agents.filter((a) => a.available); if (available.length === 0) { console.error( - '\nNo ACP providers found. Install one of:\n' + - ' npm install -g @anthropic-ai/claude-code\n' + - ' npm install -g @openai/codex\n' + - ' See https://opencode.ai/docs/cli/\n' + '\nNo ACP-compatible agents found. Install one of:\n' + + ' npm install -g @anthropic-ai/claude-code (Claude Code)\n' + + ' npm install -g @openai/codex (Codex)\n' + + ' npm install -g @anthropic-ai/claude-code (Gemini CLI)\n' + + ' See https://agentclientprotocol.com/get-started/agents\n' ); process.exit(1); } - // ── Select provider ───────────────────────────────────────────────────── - let selectedProvider: ACPProvider; + // ── Select agent ──────────────────────────────────────────────────────── + let selectedAgent: ACPAgent; if (options.provider) { - const match = providers.find((p) => p.name === options.provider); + const match = agents.find((a) => a.name === options.provider); if (!match || !match.available) { console.error( - `Provider "${options.provider}" is not installed. Available: ${available.map((p) => p.name).join(', ')}` + `Agent "${options.provider}" is not installed. Available: ${available.map((a) => a.name).join(', ')}` ); process.exit(1); } - selectedProvider = match; + selectedAgent = match; } else if (available.length === 1) { - selectedProvider = available[0]; - console.log( - `\nUsing ${selectedProvider.displayName} (only provider detected)\n` - ); + selectedAgent = available[0]; + console.log(`\nUsing ${selectedAgent.displayName} (only agent detected)\n`); } else { - const providerChoices = providers.map((p) => ({ - name: p.available - ? `● ${p.displayName} (${p.bin})` - : `○ ${p.displayName} (not installed)`, - value: p.name, - disabled: !p.available ? 'not installed' : false, + const agentChoices = agents.map((a) => ({ + name: a.available + ? `● ${a.displayName} (${a.bin})` + : `○ ${a.displayName} (not installed)`, + value: a.name, + disabled: !a.available ? 'not installed' : false, })); const chosen = await select({ - message: 'Which ACP provider?', - choices: providerChoices, + message: 'Which ACP agent?', + choices: agentChoices, }); - selectedProvider = providers.find((p) => p.name === chosen)!; + selectedAgent = agents.find((a) => a.name === chosen)!; } // ── Gather prompt ─────────────────────────────────────────────────────── @@ -271,20 +282,16 @@ export async function runInteractiveAgent(options: { // ── Create session ────────────────────────────────────────────────────── const session = createSession({ - provider: selectedProvider.name, + provider: selectedAgent.name, prompt, - schema: [], // agent will confirm schema interactively + schema: [], format, }); console.log(`\nSession: ${session.id}`); - console.log(`Output: ${session.outputPath}\n`); - - // ── Permission mode ───────────────────────────────────────────────────── - const backend = selectedProvider.name as Backend; - const skipPermissions = options.yes || (await askPermissionMode(backend)); + console.log(`Output: ${session.outputPath}`); - // ── Build and launch ──────────────────────────────────────────────────── + // ── Build message ───────────────────────────────────────────────────── const systemPrompt = buildSystemPrompt({ format, sessionDir: getSessionDir(session.id), @@ -294,6 +301,51 @@ export async function runInteractiveAgent(options: { if (urls.trim()) parts.push(`Start from these URLs: ${urls}`); const userMessage = parts.join('. ') + '.'; - console.log(`Launching ${selectedProvider.displayName}...\n`); - launchAgent(backend, systemPrompt, userMessage, skipPermissions); + // ── Connect via ACP ─────────────────────────────────────────────────── + console.log(`\nConnecting to ${selectedAgent.displayName} via ACP...\n`); + + // Handle Ctrl+C gracefully + let agent: Awaited> | null = null; + + const handleInterrupt = () => { + process.stderr.write('\n\nInterrupted.\n'); + if (agent) { + agent.cancel().catch(() => {}); + agent.close(); + } + process.exit(0); + }; + process.on('SIGINT', handleInterrupt); + + try { + agent = await connectToAgent({ + bin: selectedAgent.bin, + systemPrompt, + callbacks: { + onText: (text) => process.stdout.write(text), + onToolCall: (title, status) => { + if (status === 'pending') { + process.stderr.write(`\n⟡ ${title}...\n`); + } + }, + onToolCallUpdate: (id, status) => { + if (status === 'completed') { + process.stderr.write(` ✓ done\n`); + } + }, + }, + }); + + const result = await agent.prompt(userMessage); + process.stdout.write('\n'); + process.stderr.write( + `\nCompleted (${result.stopReason}). Output: ${session.outputPath}\n` + ); + } catch (error) { + console.error('\nError:', error instanceof Error ? error.message : error); + process.exit(1); + } finally { + process.removeListener('SIGINT', handleInterrupt); + if (agent) agent.close(); + } } From 6f440906cac0908123d3d2059ecc8bd2bcf41ad6 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:26:11 -0400 Subject: [PATCH 03/63] fix ACP agent binary names for adapters claude-agent-acp and codex-acp are the actual ACP-compatible binaries (from @zed-industries packages), not the raw CLI tools. --- src/acp/registry.ts | 6 ++---- src/commands/agent-interactive.ts | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/acp/registry.ts b/src/acp/registry.ts index 6293826..8c81007 100644 --- a/src/acp/registry.ts +++ b/src/acp/registry.ts @@ -12,15 +12,13 @@ export interface ACPAgent { } const KNOWN_AGENTS: Omit[] = [ - { name: 'claude', bin: 'claude', displayName: 'Claude Code' }, - { name: 'codex', bin: 'codex', displayName: 'Codex' }, + { name: 'claude', bin: 'claude-agent-acp', displayName: 'Claude Code' }, + { name: 'codex', bin: 'codex-acp', displayName: 'Codex' }, { name: 'gemini', bin: 'gemini', displayName: 'Gemini CLI' }, { name: 'opencode', bin: 'opencode', displayName: 'OpenCode' }, { name: 'goose', bin: 'goose', displayName: 'Goose' }, { name: 'kimi', bin: 'kimi', displayName: 'Kimi CLI' }, { name: 'augment', bin: 'augment', displayName: 'Augment Code' }, - { name: 'cursor', bin: 'cursor', displayName: 'Cursor' }, - { name: 'copilot', bin: 'github-copilot', displayName: 'GitHub Copilot' }, ]; function isBinAvailable(bin: string): boolean { diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 1e4a396..8951281 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -202,9 +202,8 @@ export async function runInteractiveAgent(options: { if (available.length === 0) { console.error( '\nNo ACP-compatible agents found. Install one of:\n' + - ' npm install -g @anthropic-ai/claude-code (Claude Code)\n' + - ' npm install -g @openai/codex (Codex)\n' + - ' npm install -g @anthropic-ai/claude-code (Gemini CLI)\n' + + ' npm install -g @zed-industries/claude-agent-acp (Claude Code)\n' + + ' npm install -g @zed-industries/codex-acp (Codex)\n' + ' See https://agentclientprotocol.com/get-started/agents\n' ); process.exit(1); From 80fc884f0a0960f455697cc45933001236f9bd92 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:40:28 -0400 Subject: [PATCH 04/63] add conversation loop to interactive agent after each agent turn, prompt the user for follow-up input so they can confirm schemas, refine results, or continue the conversation. type "done", "exit", or press enter to end the session. --- src/commands/agent-interactive.ts | 38 +++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 8951281..e32d389 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -335,11 +335,39 @@ export async function runInteractiveAgent(options: { }, }); - const result = await agent.prompt(userMessage); - process.stdout.write('\n'); - process.stderr.write( - `\nCompleted (${result.stopReason}). Output: ${session.outputPath}\n` - ); + // ── Conversation loop ───────────────────────────────────────────────── + let currentMessage = userMessage; + while (true) { + const result = await agent.prompt(currentMessage); + process.stdout.write('\n\n'); + + // If the agent stopped for a reason other than end_turn, break + if (result.stopReason !== 'end_turn') { + process.stderr.write(`Stopped (${result.stopReason}).\n`); + break; + } + + // Ask user for follow-up + const followUp = await input({ + message: '→', + default: '', + }); + + // Empty input or "done"/"exit"/"quit" ends the loop + const trimmed = followUp.trim().toLowerCase(); + if ( + !trimmed || + trimmed === 'done' || + trimmed === 'exit' || + trimmed === 'quit' + ) { + process.stderr.write(`\nSession ${session.id} saved.\n`); + process.stderr.write(`Output: ${session.outputPath}\n`); + break; + } + + currentMessage = followUp; + } } catch (error) { console.error('\nError:', error instanceof Error ? error.message : error); process.exit(1); From c5cdf189d84c34b94e97cd4841981aa9c5cda92b Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:45:33 -0400 Subject: [PATCH 05/63] show what firecrawl agent is actually doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse rawInput from ACP tool calls to show real commands: 🔥 searching "AI startups" 🔥 scraping https://example.com/pricing 🔥 writing output.csv ✅ done instead of opaque "Terminal..." / "Read File..." labels. --- src/acp/client.ts | 34 ++++++--- src/commands/agent-interactive.ts | 111 +++++++++++++++++++++++++----- 2 files changed, 118 insertions(+), 27 deletions(-) diff --git a/src/acp/client.ts b/src/acp/client.ts index 003b298..1b271c7 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -12,10 +12,18 @@ import * as acp from '@agentclientprotocol/sdk'; // ─── Types ────────────────────────────────────────────────────────────────── +export interface ToolCallInfo { + id: string; + title: string; + status: string; + rawInput?: unknown; + rawOutput?: unknown; +} + export interface ACPClientCallbacks { onText?: (text: string) => void; - onToolCall?: (title: string, status: string) => void; - onToolCallUpdate?: (toolCallId: string, status: string) => void; + onToolCall?: (call: ToolCallInfo) => void; + onToolCallUpdate?: (call: ToolCallInfo) => void; onPlan?: (entries: Array<{ content: string; status: string }>) => void; onPermissionRequest?: ( title: string, @@ -76,19 +84,25 @@ class FirecrawlClient implements acp.Client { case 'tool_call': if (this.callbacks.onToolCall) { - this.callbacks.onToolCall( - update.title ?? 'tool', - update.status ?? 'pending' - ); + this.callbacks.onToolCall({ + id: update.toolCallId, + title: update.title ?? 'tool', + status: update.status ?? 'pending', + rawInput: update.rawInput, + rawOutput: update.rawOutput, + }); } break; case 'tool_call_update': if (this.callbacks.onToolCallUpdate) { - this.callbacks.onToolCallUpdate( - update.toolCallId, - update.status ?? 'unknown' - ); + this.callbacks.onToolCallUpdate({ + id: update.toolCallId, + title: update.title ?? '', + status: update.status ?? 'unknown', + rawInput: update.rawInput, + rawOutput: update.rawOutput, + }); } break; diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index e32d389..33f9274 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -8,7 +8,7 @@ */ import { type ACPAgent, detectAgents } from '../acp/registry'; -import { connectToAgent } from '../acp/client'; +import { connectToAgent, type ToolCallInfo } from '../acp/client'; import { createSession, getSessionDir, @@ -133,6 +133,74 @@ ${outputInstructions[opts.format] || outputInstructions.json} Start by analyzing the request and proposing a schema.`; } +// ─── Tool call formatting ─────────────────────────────────────────────────── + +function formatToolCall(call: ToolCallInfo): string { + const input = call.rawInput as Record | undefined; + + // Extract the command from rawInput if available + if (input?.command && typeof input.command === 'string') { + const cmd = input.command.trim(); + + // Firecrawl commands — show them prominently + if (cmd.startsWith('firecrawl search')) { + const query = cmd + .replace(/^firecrawl search\s*/, '') + .replace(/^["']|["']$/g, ''); + return `searching "${query}"`; + } + if (cmd.startsWith('firecrawl scrape')) { + const url = cmd + .replace(/^firecrawl scrape\s*/, '') + .split(/\s/)[0] + .replace(/^["']|["']$/g, ''); + return `scraping ${url}`; + } + if (cmd.startsWith('firecrawl map')) { + const url = cmd + .replace(/^firecrawl map\s*/, '') + .split(/\s/)[0] + .replace(/^["']|["']$/g, ''); + return `mapping ${url}`; + } + if (cmd.startsWith('firecrawl crawl')) { + const url = cmd + .replace(/^firecrawl crawl\s*/, '') + .split(/\s/)[0] + .replace(/^["']|["']$/g, ''); + return `crawling ${url}`; + } + if (cmd.startsWith('firecrawl')) { + return cmd; + } + + // Write commands — show the output path + if (cmd.startsWith('cat') && cmd.includes('>')) { + const outFile = cmd.split('>').pop()?.trim() || ''; + return `writing ${outFile}`; + } + + // Generic command — show a truncated version + const short = cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd; + return short; + } + + // File operations + if (input?.path && typeof input.path === 'string') { + const basename = input.path.split('/').pop() || input.path; + if (call.title.toLowerCase().includes('write')) { + return `writing ${basename}`; + } + if (call.title.toLowerCase().includes('read')) { + return `reading ${basename}`; + } + return `${call.title.toLowerCase()} ${basename}`; + } + + // Fallback to title + return call.title.toLowerCase(); +} + // ─── Interactive flow ─────────────────────────────────────────────────────── export async function runInteractiveAgent(options: { @@ -172,16 +240,22 @@ export async function runInteractiveAgent(options: { const userMessage = `Continue from previous session. Original request: "${session.prompt}". Schema fields: ${session.schema.join(', ')}. Output already at: ${session.outputPath}. New instruction: ${refinement}`; - console.log(`\nConnecting to ${session.provider} via ACP...\n`); + console.log(`\n🔥 Reconnecting to ${session.provider} via ACP...\n`); const agent = await connectToAgent({ bin: session.provider, systemPrompt, callbacks: { onText: (text) => process.stdout.write(text), - onToolCall: (title, status) => { - if (status === 'pending') { - process.stderr.write(`\n⟡ ${title}...\n`); + onToolCall: (call: ToolCallInfo) => { + const detail = formatToolCall(call); + if (detail) { + process.stderr.write(`\n 🔥 ${detail}\n`); + } + }, + onToolCallUpdate: (call: ToolCallInfo) => { + if (call.status === 'completed') { + process.stderr.write(` ✅ done\n`); } }, }, @@ -287,8 +361,8 @@ export async function runInteractiveAgent(options: { format, }); - console.log(`\nSession: ${session.id}`); - console.log(`Output: ${session.outputPath}`); + console.log(`\n🔥 Session: ${session.id}`); + console.log(`📁 Output: ${session.outputPath}`); // ── Build message ───────────────────────────────────────────────────── const systemPrompt = buildSystemPrompt({ @@ -301,7 +375,7 @@ export async function runInteractiveAgent(options: { const userMessage = parts.join('. ') + '.'; // ── Connect via ACP ─────────────────────────────────────────────────── - console.log(`\nConnecting to ${selectedAgent.displayName} via ACP...\n`); + console.log(`\n🔥 Connecting to ${selectedAgent.displayName} via ACP...\n`); // Handle Ctrl+C gracefully let agent: Awaited> | null = null; @@ -322,14 +396,17 @@ export async function runInteractiveAgent(options: { systemPrompt, callbacks: { onText: (text) => process.stdout.write(text), - onToolCall: (title, status) => { - if (status === 'pending') { - process.stderr.write(`\n⟡ ${title}...\n`); + onToolCall: (call: ToolCallInfo) => { + const detail = formatToolCall(call); + if (detail) { + process.stderr.write(`\n 🔥 ${detail}\n`); } }, - onToolCallUpdate: (id, status) => { - if (status === 'completed') { - process.stderr.write(` ✓ done\n`); + onToolCallUpdate: (call: ToolCallInfo) => { + if (call.status === 'completed') { + process.stderr.write(` ✅ done\n`); + } else if (call.status === 'errored') { + process.stderr.write(` ❌ failed\n`); } }, }, @@ -343,7 +420,7 @@ export async function runInteractiveAgent(options: { // If the agent stopped for a reason other than end_turn, break if (result.stopReason !== 'end_turn') { - process.stderr.write(`Stopped (${result.stopReason}).\n`); + process.stderr.write(`\n🔥 Stopped (${result.stopReason}).\n`); break; } @@ -361,8 +438,8 @@ export async function runInteractiveAgent(options: { trimmed === 'exit' || trimmed === 'quit' ) { - process.stderr.write(`\nSession ${session.id} saved.\n`); - process.stderr.write(`Output: ${session.outputPath}\n`); + process.stderr.write(`\n🔥 Session ${session.id} saved.\n`); + process.stderr.write(`📁 Output: ${session.outputPath}\n`); break; } From 6a1b5a6d62e1e90dda927a01a40c657515369bac Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:50:36 -0400 Subject: [PATCH 06/63] clean up tool call display and branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proper casing, aligned columns, professional formatting: 🔥 Search "AI startups" Done 🔥 Scrape https://example.com/pricing Done 🔥 Write output.csv Done --- src/commands/agent-interactive.ts | 72 +++++++++++++++---------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 33f9274..44b30cf 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -135,6 +135,13 @@ Start by analyzing the request and proposing a schema.`; // ─── Tool call formatting ─────────────────────────────────────────────────── +function extractFirecrawlArg(cmd: string, prefix: string): string { + return cmd + .replace(new RegExp(`^${prefix}\\s*`), '') + .split(/\s/)[0] + .replace(/^["']|["']$/g, ''); +} + function formatToolCall(call: ToolCallInfo): string { const input = call.rawInput as Record | undefined; @@ -142,63 +149,52 @@ function formatToolCall(call: ToolCallInfo): string { if (input?.command && typeof input.command === 'string') { const cmd = input.command.trim(); - // Firecrawl commands — show them prominently + // Firecrawl commands if (cmd.startsWith('firecrawl search')) { - const query = cmd - .replace(/^firecrawl search\s*/, '') - .replace(/^["']|["']$/g, ''); - return `searching "${query}"`; + const query = extractFirecrawlArg(cmd, 'firecrawl search'); + return `Search "${query}"`; } if (cmd.startsWith('firecrawl scrape')) { - const url = cmd - .replace(/^firecrawl scrape\s*/, '') - .split(/\s/)[0] - .replace(/^["']|["']$/g, ''); - return `scraping ${url}`; + const url = extractFirecrawlArg(cmd, 'firecrawl scrape'); + return `Scrape ${url}`; } if (cmd.startsWith('firecrawl map')) { - const url = cmd - .replace(/^firecrawl map\s*/, '') - .split(/\s/)[0] - .replace(/^["']|["']$/g, ''); - return `mapping ${url}`; + const url = extractFirecrawlArg(cmd, 'firecrawl map'); + return `Map ${url}`; } if (cmd.startsWith('firecrawl crawl')) { - const url = cmd - .replace(/^firecrawl crawl\s*/, '') - .split(/\s/)[0] - .replace(/^["']|["']$/g, ''); - return `crawling ${url}`; + const url = extractFirecrawlArg(cmd, 'firecrawl crawl'); + return `Crawl ${url}`; } if (cmd.startsWith('firecrawl')) { - return cmd; + return `Run ${cmd}`; } - // Write commands — show the output path + // Write commands if (cmd.startsWith('cat') && cmd.includes('>')) { const outFile = cmd.split('>').pop()?.trim() || ''; - return `writing ${outFile}`; + return `Write ${outFile}`; } - // Generic command — show a truncated version + // Generic command const short = cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd; - return short; + return `Run ${short}`; } // File operations if (input?.path && typeof input.path === 'string') { const basename = input.path.split('/').pop() || input.path; if (call.title.toLowerCase().includes('write')) { - return `writing ${basename}`; + return `Write ${basename}`; } if (call.title.toLowerCase().includes('read')) { - return `reading ${basename}`; + return `Read ${basename}`; } - return `${call.title.toLowerCase()} ${basename}`; + return `${call.title} ${basename}`; } - // Fallback to title - return call.title.toLowerCase(); + // Fallback + return call.title; } // ─── Interactive flow ─────────────────────────────────────────────────────── @@ -240,7 +236,7 @@ export async function runInteractiveAgent(options: { const userMessage = `Continue from previous session. Original request: "${session.prompt}". Schema fields: ${session.schema.join(', ')}. Output already at: ${session.outputPath}. New instruction: ${refinement}`; - console.log(`\n🔥 Reconnecting to ${session.provider} via ACP...\n`); + console.log(`\n🔥 Resuming session via Agent Client Protocol...\n`); const agent = await connectToAgent({ bin: session.provider, @@ -250,12 +246,12 @@ export async function runInteractiveAgent(options: { onToolCall: (call: ToolCallInfo) => { const detail = formatToolCall(call); if (detail) { - process.stderr.write(`\n 🔥 ${detail}\n`); + process.stderr.write(` 🔥 ${detail}\n`); } }, onToolCallUpdate: (call: ToolCallInfo) => { if (call.status === 'completed') { - process.stderr.write(` ✅ done\n`); + process.stderr.write(` Done\n`); } }, }, @@ -375,7 +371,9 @@ export async function runInteractiveAgent(options: { const userMessage = parts.join('. ') + '.'; // ── Connect via ACP ─────────────────────────────────────────────────── - console.log(`\n🔥 Connecting to ${selectedAgent.displayName} via ACP...\n`); + console.log( + `\n🔥 Loading ${selectedAgent.displayName} via Agent Client Protocol...\n` + ); // Handle Ctrl+C gracefully let agent: Awaited> | null = null; @@ -399,14 +397,14 @@ export async function runInteractiveAgent(options: { onToolCall: (call: ToolCallInfo) => { const detail = formatToolCall(call); if (detail) { - process.stderr.write(`\n 🔥 ${detail}\n`); + process.stderr.write(` 🔥 ${detail}\n`); } }, onToolCallUpdate: (call: ToolCallInfo) => { if (call.status === 'completed') { - process.stderr.write(` ✅ done\n`); + process.stderr.write(` Done\n`); } else if (call.status === 'errored') { - process.stderr.write(` ❌ failed\n`); + process.stderr.write(` Failed\n`); } }, }, From 7a4e4de6fd2ecbe619c54f5cf7360577efc1aae7 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:57:28 -0400 Subject: [PATCH 07/63] redesign tool call UX with spinner and smart filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit only show firecrawl operations (search, scrape, map, crawl) and session output writes. hide internal operations (reads, grep, python scripts, help lookups, temp files). animated spinner for in-progress work, checkmark on completion. ⠹ Searching "AI startups" ✓ Searching "AI startups" ⠼ Scraping forbes.com/lists/ai50 ✓ Scraping forbes.com/lists/ai50 ✓ Writing output.csv --- src/commands/agent-interactive.ts | 198 ++++++++++++++++++------------ 1 file changed, 121 insertions(+), 77 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 44b30cf..6dec25f 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -133,68 +133,137 @@ ${outputInstructions[opts.format] || outputInstructions.json} Start by analyzing the request and proposing a schema.`; } -// ─── Tool call formatting ─────────────────────────────────────────────────── +// ─── Tool call display ────────────────────────────────────────────────────── -function extractFirecrawlArg(cmd: string, prefix: string): string { - return cmd - .replace(new RegExp(`^${prefix}\\s*`), '') - .split(/\s/)[0] - .replace(/^["']|["']$/g, ''); -} - -function formatToolCall(call: ToolCallInfo): string { +/** + * Parse a tool call and return a user-facing label, or null to hide it. + * Only firecrawl operations and session output writes are shown. + */ +function describeToolCall( + call: ToolCallInfo, + sessionDir: string +): string | null { const input = call.rawInput as Record | undefined; - // Extract the command from rawInput if available if (input?.command && typeof input.command === 'string') { const cmd = input.command.trim(); - // Firecrawl commands if (cmd.startsWith('firecrawl search')) { - const query = extractFirecrawlArg(cmd, 'firecrawl search'); - return `Search "${query}"`; + const query = cmd + .replace(/^firecrawl search\s*/, '') + .replace(/^["']|["']$/g, '') + .split(/\s+--/)[0]; + return `Searching "${query}"`; } if (cmd.startsWith('firecrawl scrape')) { - const url = extractFirecrawlArg(cmd, 'firecrawl scrape'); - return `Scrape ${url}`; + const url = cmd + .replace(/^firecrawl scrape\s*/, '') + .split(/\s/)[0] + .replace(/^["']|["']$/g, ''); + if (url.startsWith('http')) return `Scraping ${url}`; + return null; // --help or flags only } if (cmd.startsWith('firecrawl map')) { - const url = extractFirecrawlArg(cmd, 'firecrawl map'); - return `Map ${url}`; + const url = cmd + .replace(/^firecrawl map\s*/, '') + .split(/\s/)[0] + .replace(/^["']|["']$/g, ''); + if (url.startsWith('http')) return `Mapping ${url}`; + return null; } if (cmd.startsWith('firecrawl crawl')) { - const url = extractFirecrawlArg(cmd, 'firecrawl crawl'); - return `Crawl ${url}`; - } - if (cmd.startsWith('firecrawl')) { - return `Run ${cmd}`; + const url = cmd + .replace(/^firecrawl crawl\s*/, '') + .split(/\s/)[0] + .replace(/^["']|["']$/g, ''); + if (url.startsWith('http')) return `Crawling ${url}`; + return null; } - - // Write commands - if (cmd.startsWith('cat') && cmd.includes('>')) { - const outFile = cmd.split('>').pop()?.trim() || ''; - return `Write ${outFile}`; + if (cmd.startsWith('firecrawl agent')) { + return 'Running Firecrawl extraction agent'; } - // Generic command - const short = cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd; - return `Run ${short}`; + // Hide everything else (grep, python, cat, temp files, help, etc.) + return null; } - // File operations + // File writes to the session directory are shown if (input?.path && typeof input.path === 'string') { - const basename = input.path.split('/').pop() || input.path; - if (call.title.toLowerCase().includes('write')) { - return `Write ${basename}`; - } - if (call.title.toLowerCase().includes('read')) { - return `Read ${basename}`; + if ( + input.path.startsWith(sessionDir) && + call.title.toLowerCase().includes('write') + ) { + const basename = input.path.split('/').pop() || input.path; + return `Writing ${basename}`; } - return `${call.title} ${basename}`; + return null; } - // Fallback - return call.title; + return null; +} + +// Track active tool calls for spinner display +const activeToolCalls = new Map(); +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +let spinnerFrame = 0; +let spinnerInterval: ReturnType | null = null; + +function startSpinner(): void { + if (spinnerInterval) return; + spinnerInterval = setInterval(() => { + if (activeToolCalls.size === 0) return; + spinnerFrame = (spinnerFrame + 1) % SPINNER.length; + const labels = [...activeToolCalls.values()]; + const display = + labels.length === 1 + ? labels[0] + : `${labels[0]} (+${labels.length - 1} more)`; + process.stderr.write( + `\r ${SPINNER[spinnerFrame]} ${display}${''.padEnd(20)}` + ); + }, 80); +} + +function stopSpinner(): void { + if (spinnerInterval) { + clearInterval(spinnerInterval); + spinnerInterval = null; + } +} + +function clearSpinnerLine(): void { + process.stderr.write(`\r${''.padEnd(100)}\r`); +} + +function buildCallbacks(sessionDir: string): { + onText: (text: string) => void; + onToolCall: (call: ToolCallInfo) => void; + onToolCallUpdate: (call: ToolCallInfo) => void; +} { + return { + onText: (text: string) => { + // Clear spinner line before printing text + if (activeToolCalls.size > 0) clearSpinnerLine(); + process.stdout.write(text); + }, + onToolCall: (call: ToolCallInfo) => { + const label = describeToolCall(call, sessionDir); + if (!label) return; + activeToolCalls.set(call.id, label); + startSpinner(); + }, + onToolCallUpdate: (call: ToolCallInfo) => { + if (!activeToolCalls.has(call.id)) return; + const label = activeToolCalls.get(call.id)!; + if (call.status === 'completed' || call.status === 'errored') { + activeToolCalls.delete(call.id); + clearSpinnerLine(); + const icon = call.status === 'completed' ? '✓' : '✗'; + process.stderr.write(` ${icon} ${label}\n`); + if (activeToolCalls.size === 0) stopSpinner(); + } + }, + }; } // ─── Interactive flow ─────────────────────────────────────────────────────── @@ -236,25 +305,12 @@ export async function runInteractiveAgent(options: { const userMessage = `Continue from previous session. Original request: "${session.prompt}". Schema fields: ${session.schema.join(', ')}. Output already at: ${session.outputPath}. New instruction: ${refinement}`; - console.log(`\n🔥 Resuming session via Agent Client Protocol...\n`); + console.log(`\nResuming session via Agent Client Protocol...\n`); const agent = await connectToAgent({ bin: session.provider, systemPrompt, - callbacks: { - onText: (text) => process.stdout.write(text), - onToolCall: (call: ToolCallInfo) => { - const detail = formatToolCall(call); - if (detail) { - process.stderr.write(` 🔥 ${detail}\n`); - } - }, - onToolCallUpdate: (call: ToolCallInfo) => { - if (call.status === 'completed') { - process.stderr.write(` Done\n`); - } - }, - }, + callbacks: buildCallbacks(getSessionDir(session.id)), }); try { @@ -357,8 +413,8 @@ export async function runInteractiveAgent(options: { format, }); - console.log(`\n🔥 Session: ${session.id}`); - console.log(`📁 Output: ${session.outputPath}`); + console.log(`\nSession ${session.id}`); + console.log(`Output ${session.outputPath}`); // ── Build message ───────────────────────────────────────────────────── const systemPrompt = buildSystemPrompt({ @@ -379,7 +435,9 @@ export async function runInteractiveAgent(options: { let agent: Awaited> | null = null; const handleInterrupt = () => { - process.stderr.write('\n\nInterrupted.\n'); + stopSpinner(); + clearSpinnerLine(); + process.stderr.write('\nInterrupted.\n'); if (agent) { agent.cancel().catch(() => {}); agent.close(); @@ -392,22 +450,7 @@ export async function runInteractiveAgent(options: { agent = await connectToAgent({ bin: selectedAgent.bin, systemPrompt, - callbacks: { - onText: (text) => process.stdout.write(text), - onToolCall: (call: ToolCallInfo) => { - const detail = formatToolCall(call); - if (detail) { - process.stderr.write(` 🔥 ${detail}\n`); - } - }, - onToolCallUpdate: (call: ToolCallInfo) => { - if (call.status === 'completed') { - process.stderr.write(` Done\n`); - } else if (call.status === 'errored') { - process.stderr.write(` Failed\n`); - } - }, - }, + callbacks: buildCallbacks(getSessionDir(session.id)), }); // ── Conversation loop ───────────────────────────────────────────────── @@ -418,7 +461,7 @@ export async function runInteractiveAgent(options: { // If the agent stopped for a reason other than end_turn, break if (result.stopReason !== 'end_turn') { - process.stderr.write(`\n🔥 Stopped (${result.stopReason}).\n`); + process.stderr.write(`\nStopped (${result.stopReason}).\n`); break; } @@ -436,8 +479,8 @@ export async function runInteractiveAgent(options: { trimmed === 'exit' || trimmed === 'quit' ) { - process.stderr.write(`\n🔥 Session ${session.id} saved.\n`); - process.stderr.write(`📁 Output: ${session.outputPath}\n`); + process.stderr.write(`\nSession ${session.id} saved.\n`); + process.stderr.write(`Output ${session.outputPath}\n`); break; } @@ -447,6 +490,7 @@ export async function runInteractiveAgent(options: { console.error('\nError:', error instanceof Error ? error.message : error); process.exit(1); } finally { + stopSpinner(); process.removeListener('SIGINT', handleInterrupt); if (agent) agent.close(); } From 833b57de796f1f1d676a7b002742f84493853556 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:00:31 -0400 Subject: [PATCH 08/63] multi-line spinner for parallel firecrawl operations each concurrent operation gets its own spinner line instead of collapsing into "(+N more)". lines clear and re-render as operations complete. ANSI escape sequences for smooth updates. --- src/commands/agent-interactive.ts | 114 ++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 39 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 6dec25f..70ecc37 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -202,67 +202,103 @@ function describeToolCall( return null; } -// Track active tool calls for spinner display -const activeToolCalls = new Map(); +// ─── Multi-line spinner for parallel operations ───────────────────────────── + const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; -let spinnerFrame = 0; -let spinnerInterval: ReturnType | null = null; - -function startSpinner(): void { - if (spinnerInterval) return; - spinnerInterval = setInterval(() => { - if (activeToolCalls.size === 0) return; - spinnerFrame = (spinnerFrame + 1) % SPINNER.length; - const labels = [...activeToolCalls.values()]; - const display = - labels.length === 1 - ? labels[0] - : `${labels[0]} (+${labels.length - 1} more)`; - process.stderr.write( - `\r ${SPINNER[spinnerFrame]} ${display}${''.padEnd(20)}` - ); - }, 80); + +interface ToolCallState { + activeToolCalls: Map; + spinnerFrame: number; + spinnerInterval: ReturnType | null; + renderedLineCount: number; +} + +function createToolCallState(): ToolCallState { + return { + activeToolCalls: new Map(), + spinnerFrame: 0, + spinnerInterval: null, + renderedLineCount: 0, + }; +} + +function clearRenderedLines(state: ToolCallState): void { + for (let i = 0; i < state.renderedLineCount; i++) { + process.stderr.write('\x1b[A\x1b[2K'); // move up + clear line + } + state.renderedLineCount = 0; } -function stopSpinner(): void { - if (spinnerInterval) { - clearInterval(spinnerInterval); - spinnerInterval = null; +function renderSpinnerFrame(state: ToolCallState): void { + if (state.activeToolCalls.size === 0) return; + state.spinnerFrame = (state.spinnerFrame + 1) % SPINNER.length; + + // Clear previously rendered lines + clearRenderedLines(state); + + // Render each active call on its own line + const lines = [...state.activeToolCalls.values()]; + for (const label of lines) { + process.stderr.write(` ${SPINNER[state.spinnerFrame]} ${label}\n`); } + state.renderedLineCount = lines.length; } -function clearSpinnerLine(): void { - process.stderr.write(`\r${''.padEnd(100)}\r`); +function startSpinner(state: ToolCallState): void { + if (state.spinnerInterval) return; + state.spinnerInterval = setInterval(() => renderSpinnerFrame(state), 80); +} + +function stopSpinner(state: ToolCallState): void { + if (state.spinnerInterval) { + clearInterval(state.spinnerInterval); + state.spinnerInterval = null; + } + clearRenderedLines(state); } function buildCallbacks(sessionDir: string): { onText: (text: string) => void; onToolCall: (call: ToolCallInfo) => void; onToolCallUpdate: (call: ToolCallInfo) => void; + cleanup: () => void; } { + const state = createToolCallState(); + return { onText: (text: string) => { - // Clear spinner line before printing text - if (activeToolCalls.size > 0) clearSpinnerLine(); - process.stdout.write(text); + // Clear spinner, print text, re-render spinner + if (state.activeToolCalls.size > 0) { + clearRenderedLines(state); + process.stdout.write(text); + renderSpinnerFrame(state); + } else { + process.stdout.write(text); + } }, onToolCall: (call: ToolCallInfo) => { const label = describeToolCall(call, sessionDir); if (!label) return; - activeToolCalls.set(call.id, label); - startSpinner(); + state.activeToolCalls.set(call.id, label); + startSpinner(state); }, onToolCallUpdate: (call: ToolCallInfo) => { - if (!activeToolCalls.has(call.id)) return; - const label = activeToolCalls.get(call.id)!; + if (!state.activeToolCalls.has(call.id)) return; + const label = state.activeToolCalls.get(call.id)!; if (call.status === 'completed' || call.status === 'errored') { - activeToolCalls.delete(call.id); - clearSpinnerLine(); + state.activeToolCalls.delete(call.id); + + // Clear all spinner lines, print completed line, re-render remaining + clearRenderedLines(state); const icon = call.status === 'completed' ? '✓' : '✗'; process.stderr.write(` ${icon} ${label}\n`); - if (activeToolCalls.size === 0) stopSpinner(); + + if (state.activeToolCalls.size === 0) { + stopSpinner(state); + } } }, + cleanup: () => stopSpinner(state), }; } @@ -433,10 +469,10 @@ export async function runInteractiveAgent(options: { // Handle Ctrl+C gracefully let agent: Awaited> | null = null; + const callbacks = buildCallbacks(getSessionDir(session.id)); const handleInterrupt = () => { - stopSpinner(); - clearSpinnerLine(); + callbacks.cleanup(); process.stderr.write('\nInterrupted.\n'); if (agent) { agent.cancel().catch(() => {}); @@ -450,7 +486,7 @@ export async function runInteractiveAgent(options: { agent = await connectToAgent({ bin: selectedAgent.bin, systemPrompt, - callbacks: buildCallbacks(getSessionDir(session.id)), + callbacks, }); // ── Conversation loop ───────────────────────────────────────────────── @@ -490,7 +526,7 @@ export async function runInteractiveAgent(options: { console.error('\nError:', error instanceof Error ? error.message : error); process.exit(1); } finally { - stopSpinner(); + callbacks.cleanup(); process.removeListener('SIGINT', handleInterrupt); if (agent) agent.close(); } From ce17a5278590d6d23012e176032194e4dfec741c Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:03:00 -0400 Subject: [PATCH 09/63] fix spinner spam and clean up tool call display - replace broken ANSI multi-line spinner with simple start/done line pairs that don't fight with streaming text - fix search query parsing to strip pipes and redirects (was showing "query" 2>&1 | head -2000") - group unavailable agents at bottom of selection with separator --- src/commands/agent-interactive.ts | 168 ++++++++++++------------------ 1 file changed, 65 insertions(+), 103 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 70ecc37..11d3822 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -139,6 +139,22 @@ Start by analyzing the request and proposing a schema.`; * Parse a tool call and return a user-facing label, or null to hide it. * Only firecrawl operations and session output writes are shown. */ +/** Extract a URL from a firecrawl command, ignoring flags, pipes, and redirects. */ +function extractUrl(cmd: string, prefix: string): string | null { + // Try quoted URL first + const quoted = cmd.match( + new RegExp(`${prefix}\\s+["'](https?://[^"']+)["']`) + ); + if (quoted) return quoted[1]; + // Try unquoted URL + const parts = cmd.replace(new RegExp(`^${prefix}\\s*`), '').split(/\s+/); + for (const part of parts) { + const clean = part.replace(/^["']|["']$/g, ''); + if (clean.startsWith('http')) return clean; + } + return null; +} + function describeToolCall( call: ToolCallInfo, sessionDir: string @@ -149,34 +165,28 @@ function describeToolCall( const cmd = input.command.trim(); if (cmd.startsWith('firecrawl search')) { - const query = cmd + // Extract just the quoted query, stripping flags and pipes + const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); + if (match) return `Searching "${match[1]}"`; + const arg = cmd .replace(/^firecrawl search\s*/, '') - .replace(/^["']|["']$/g, '') - .split(/\s+--/)[0]; - return `Searching "${query}"`; + .split(/\s+/)[0] + .replace(/^["']|["']$/g, ''); + return `Searching "${arg}"`; } if (cmd.startsWith('firecrawl scrape')) { - const url = cmd - .replace(/^firecrawl scrape\s*/, '') - .split(/\s/)[0] - .replace(/^["']|["']$/g, ''); - if (url.startsWith('http')) return `Scraping ${url}`; - return null; // --help or flags only + const url = extractUrl(cmd, 'firecrawl scrape'); + if (url) return `Scraping ${url}`; + return null; } if (cmd.startsWith('firecrawl map')) { - const url = cmd - .replace(/^firecrawl map\s*/, '') - .split(/\s/)[0] - .replace(/^["']|["']$/g, ''); - if (url.startsWith('http')) return `Mapping ${url}`; + const url = extractUrl(cmd, 'firecrawl map'); + if (url) return `Mapping ${url}`; return null; } if (cmd.startsWith('firecrawl crawl')) { - const url = cmd - .replace(/^firecrawl crawl\s*/, '') - .split(/\s/)[0] - .replace(/^["']|["']$/g, ''); - if (url.startsWith('http')) return `Crawling ${url}`; + const url = extractUrl(cmd, 'firecrawl crawl'); + if (url) return `Crawling ${url}`; return null; } if (cmd.startsWith('firecrawl agent')) { @@ -202,60 +212,7 @@ function describeToolCall( return null; } -// ─── Multi-line spinner for parallel operations ───────────────────────────── - -const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -interface ToolCallState { - activeToolCalls: Map; - spinnerFrame: number; - spinnerInterval: ReturnType | null; - renderedLineCount: number; -} - -function createToolCallState(): ToolCallState { - return { - activeToolCalls: new Map(), - spinnerFrame: 0, - spinnerInterval: null, - renderedLineCount: 0, - }; -} - -function clearRenderedLines(state: ToolCallState): void { - for (let i = 0; i < state.renderedLineCount; i++) { - process.stderr.write('\x1b[A\x1b[2K'); // move up + clear line - } - state.renderedLineCount = 0; -} - -function renderSpinnerFrame(state: ToolCallState): void { - if (state.activeToolCalls.size === 0) return; - state.spinnerFrame = (state.spinnerFrame + 1) % SPINNER.length; - - // Clear previously rendered lines - clearRenderedLines(state); - - // Render each active call on its own line - const lines = [...state.activeToolCalls.values()]; - for (const label of lines) { - process.stderr.write(` ${SPINNER[state.spinnerFrame]} ${label}\n`); - } - state.renderedLineCount = lines.length; -} - -function startSpinner(state: ToolCallState): void { - if (state.spinnerInterval) return; - state.spinnerInterval = setInterval(() => renderSpinnerFrame(state), 80); -} - -function stopSpinner(state: ToolCallState): void { - if (state.spinnerInterval) { - clearInterval(state.spinnerInterval); - state.spinnerInterval = null; - } - clearRenderedLines(state); -} +// ─── Tool call display ────────────────────────────────────────────────────── function buildCallbacks(sessionDir: string): { onText: (text: string) => void; @@ -263,42 +220,28 @@ function buildCallbacks(sessionDir: string): { onToolCallUpdate: (call: ToolCallInfo) => void; cleanup: () => void; } { - const state = createToolCallState(); + const pending = new Map(); return { onText: (text: string) => { - // Clear spinner, print text, re-render spinner - if (state.activeToolCalls.size > 0) { - clearRenderedLines(state); - process.stdout.write(text); - renderSpinnerFrame(state); - } else { - process.stdout.write(text); - } + process.stdout.write(text); }, onToolCall: (call: ToolCallInfo) => { const label = describeToolCall(call, sessionDir); if (!label) return; - state.activeToolCalls.set(call.id, label); - startSpinner(state); + pending.set(call.id, label); + process.stderr.write(` · ${label}\n`); }, onToolCallUpdate: (call: ToolCallInfo) => { - if (!state.activeToolCalls.has(call.id)) return; - const label = state.activeToolCalls.get(call.id)!; + if (!pending.has(call.id)) return; + const label = pending.get(call.id)!; if (call.status === 'completed' || call.status === 'errored') { - state.activeToolCalls.delete(call.id); - - // Clear all spinner lines, print completed line, re-render remaining - clearRenderedLines(state); + pending.delete(call.id); const icon = call.status === 'completed' ? '✓' : '✗'; process.stderr.write(` ${icon} ${label}\n`); - - if (state.activeToolCalls.size === 0) { - stopSpinner(state); - } } }, - cleanup: () => stopSpinner(state), + cleanup: () => {}, }; } @@ -387,13 +330,32 @@ export async function runInteractiveAgent(options: { selectedAgent = available[0]; console.log(`\nUsing ${selectedAgent.displayName} (only agent detected)\n`); } else { - const agentChoices = agents.map((a) => ({ - name: a.available - ? `● ${a.displayName} (${a.bin})` - : `○ ${a.displayName} (not installed)`, - value: a.name, - disabled: !a.available ? 'not installed' : false, - })); + // Available agents first, then unavailable grouped at the bottom + const installedChoices = agents + .filter((a) => a.available) + .map((a) => ({ + name: `${a.displayName}`, + value: a.name, + disabled: false as const, + })); + const notInstalled = agents.filter((a) => !a.available); + const agentChoices = [ + ...installedChoices, + ...(notInstalled.length > 0 + ? [ + { + name: '─── Not installed ───', + value: '_sep', + disabled: 'separator' as const, + }, + ...notInstalled.map((a) => ({ + name: `${a.displayName}`, + value: a.name, + disabled: 'not installed' as const, + })), + ] + : []), + ]; const chosen = await select({ message: 'Which ACP agent?', From 076ce4d53ad2d7bc22901331add4901197048614 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:09:33 -0400 Subject: [PATCH 10/63] add ink TUI with status bar, credits, and elapsed time ink-based terminal UI that shows: - animated spinners per active firecrawl operation - checkmarks on completion - persistent status bar: session | agent | credits | time | format - unmounts between turns for clean user input prompts also: suppress agent stderr noise (hook warnings), enable JSX in tsconfig for .tsx support, add ink + react deps. --- package.json | 5 +- pnpm-lock.yaml | 287 ++++++++++++++++++++++++++++++ src/acp/client.ts | 7 +- src/acp/tui.tsx | 286 +++++++++++++++++++++++++++++ src/commands/agent-interactive.ts | 28 ++- tsconfig.json | 7 +- 6 files changed, 609 insertions(+), 11 deletions(-) create mode 100644 src/acp/tui.tsx diff --git a/package.json b/package.json index d7157e8..cbac49a 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "packageManager": "pnpm@10.12.1", "devDependencies": { "@types/node": "^20.0.0", + "@types/react": "^19.2.14", "husky": "^9.0.0", "lint-staged": "^15.0.0", "prettier": "^3.0.0", @@ -74,6 +75,8 @@ "@agentclientprotocol/sdk": "^0.17.0", "@inquirer/prompts": "^8.2.1", "@mendable/firecrawl-js": "4.17.0", - "commander": "^14.0.2" + "commander": "^14.0.2", + "ink": "^6.8.0", + "react": "^19.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8438349..20e2465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,10 +20,19 @@ importers: commander: specifier: ^14.0.2 version: 14.0.2 + ink: + specifier: ^6.8.0 + version: 6.8.0(@types/react@19.2.14)(react@19.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 devDependencies: '@types/node': specifier: ^20.0.0 version: 20.19.27 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 husky: specifier: ^9.0.0 version: 9.1.7 @@ -47,6 +56,10 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@alcalzone/ansi-tokenize@0.2.5': + resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -484,6 +497,9 @@ packages: '@types/node@20.19.27': resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@vitest/expect@4.0.16': resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} @@ -517,6 +533,10 @@ packages: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -532,6 +552,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} @@ -554,6 +578,14 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -562,10 +594,18 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -581,10 +621,17 @@ packages: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -628,11 +675,18 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -698,6 +752,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -739,6 +797,23 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + ink@6.8.0: + resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + is-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} engines: {node: '>=12'} @@ -747,6 +822,11 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -797,6 +877,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -824,6 +908,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -832,6 +920,10 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -871,6 +963,20 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -886,6 +992,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -897,6 +1006,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -909,10 +1021,18 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -927,6 +1047,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -935,6 +1059,14 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -954,6 +1086,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + engines: {node: '>=20'} + typescript-event-target@1.1.2: resolution: {integrity: sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==} @@ -1049,15 +1185,34 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} hasBin: true + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -1072,6 +1227,11 @@ snapshots: dependencies: zod: 3.25.76 + '@alcalzone/ansi-tokenize@0.2.5': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -1371,6 +1531,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + '@vitest/expect@4.0.16': dependencies: '@standard-schema/spec': 1.1.0 @@ -1414,6 +1578,10 @@ snapshots: dependencies: environment: 1.1.0 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@6.2.2: {} ansi-styles@6.2.3: {} @@ -1422,6 +1590,8 @@ snapshots: asynckit@0.4.0: {} + auto-bind@5.0.1: {} + axios@1.13.6: dependencies: follow-redirects: 1.15.11 @@ -1445,6 +1615,12 @@ snapshots: chardet@2.1.1: {} + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -1454,8 +1630,17 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + cli-width@4.1.0: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + colorette@2.0.20: {} combined-stream@1.0.8: @@ -1466,12 +1651,16 @@ snapshots: commander@14.0.2: {} + convert-to-spaces@2.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1505,6 +1694,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -1534,6 +1725,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + escape-string-regexp@2.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -1598,6 +1791,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1638,12 +1833,50 @@ snapshots: dependencies: safer-buffer: 2.1.2 + indent-string@5.0.0: {} + + ink@6.8.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.5 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.2.0 + code-excerpt: 4.0.0 + es-toolkit: 1.45.1 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.4 + react-reconciler: 0.33.0(react@19.2.4) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 8.0.0 + stack-utils: 2.0.6 + string-width: 8.2.0 + terminal-size: 4.0.1 + type-fest: 5.5.0 + widest-line: 6.0.0 + wrap-ansi: 9.0.2 + ws: 8.20.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + is-fullwidth-code-point@4.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.4.0 + is-in-ci@2.0.0: {} + is-number@7.0.0: {} is-stream@3.0.0: {} @@ -1703,6 +1936,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -1719,6 +1954,10 @@ snapshots: obug@2.1.1: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -1727,6 +1966,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + patch-console@2.0.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -1751,6 +1992,18 @@ snapshots: proxy-from-env@1.1.0: {} + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react@19.2.4: {} + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -1791,6 +2044,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -1799,6 +2054,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} slice-ansi@5.0.0: @@ -1811,8 +2068,17 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@3.10.0: {} @@ -1825,12 +2091,21 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 strip-final-newline@3.0.0: {} + tagged-tag@1.0.0: {} + + terminal-size@4.0.1: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -1846,6 +2121,10 @@ snapshots: dependencies: is-number: 7.0.0 + type-fest@5.5.0: + dependencies: + tagged-tag: 1.0.0 + typescript-event-target@1.1.2: {} typescript@5.9.3: {} @@ -1911,14 +2190,22 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.1.2 + ws@8.20.0: {} + yaml@2.8.2: {} + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/acp/client.ts b/src/acp/client.ts index 1b271c7..cced0fa 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -160,12 +160,15 @@ export async function connectToAgent(opts: { cancel: () => Promise; close: () => void; }> { - // Spawn agent subprocess + // Spawn agent subprocess — pipe stderr to suppress agent noise const agentProcess = spawn(opts.bin, opts.args ?? [], { - stdio: ['pipe', 'pipe', 'inherit'], + stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd ?? process.cwd(), }); + // Silently discard agent's stderr (hook warnings, debug output, etc.) + agentProcess.stderr?.resume(); + // Handle spawn errors const spawnError = new Promise((_, reject) => { agentProcess.on('error', (err) => { diff --git a/src/acp/tui.tsx b/src/acp/tui.tsx new file mode 100644 index 0000000..ce88c89 --- /dev/null +++ b/src/acp/tui.tsx @@ -0,0 +1,286 @@ +/** + * Firecrawl Agent TUI — ink-based terminal UI for active turns. + * + * Renders a status bar while the agent is working, with credits, + * elapsed time, and active tool calls. Unmounts between turns + * so inquirer prompts work normally. + */ + +import React, { useState, useEffect } from 'react'; +import { render, Box, Text, Static } from 'ink'; +import type { ToolCallInfo } from './client.js'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ToolCallDisplay { + id: string; + label: string; + status: 'active' | 'done' | 'error'; + startedAt: number; +} + +// ─── Spinner ──────────────────────────────────────────────────────────────── + +const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +function Spinner() { + const [frame, setFrame] = useState(0); + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80); + return () => clearInterval(id); + }, []); + return {FRAMES[frame]}; +} + +// ─── Status Bar ───────────────────────────────────────────────────────────── + +function StatusBar({ + sessionId, + agentName, + credits, + startedAt, + outputFormat, +}: { + sessionId: string; + agentName: string; + credits: number; + startedAt: number; + outputFormat: string; +}) { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + const id = setInterval( + () => setElapsed(Math.round((Date.now() - startedAt) / 1000)), + 1000 + ); + return () => clearInterval(id); + }, [startedAt]); + + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + + return ( + + {sessionId} + | + {agentName} + | + {credits} credits + | + {time} + | + {outputFormat.toUpperCase()} + + ); +} + +// ─── Tool Call Lines ──────────────────────────────────────────────────────── + +function ToolCallLine({ call }: { call: ToolCallDisplay }) { + return ( + + + {call.status === 'active' ? ( + + ) : call.status === 'done' ? ( + + ) : ( + + )} + {call.label} + + ); +} + +// ─── Main App ─────────────────────────────────────────────────────────────── + +function App({ + stateRef, +}: { + stateRef: { current: TUIState }; +}) { + const [, setTick] = useState(0); + + useEffect(() => { + stateRef.current.rerender = () => setTick((t) => t + 1); + return () => { + stateRef.current.rerender = null; + }; + }, []); + + const s = stateRef.current; + + // Completed and active tool calls + const completed = s.toolCalls.filter((t) => t.status !== 'active'); + const active = s.toolCalls.filter((t) => t.status === 'active'); + + return ( + + {/* Completed tool calls — static, won't re-render */} + + {(call) => } + + + {/* Active tool calls — animated spinners */} + {active.map((call) => ( + + ))} + + {/* Status bar */} + + + ); +} + +// ─── State + Controller ───────────────────────────────────────────────────── + +interface TUIState { + toolCalls: ToolCallDisplay[]; + credits: number; + startedAt: number; + sessionId: string; + agentName: string; + outputFormat: string; + rerender: (() => void) | null; +} + +/** Extract a display label from a tool call, or null to hide it. */ +function describeCall( + call: ToolCallInfo, + sessionDir: string +): string | null { + const input = call.rawInput as Record | undefined; + + if (input?.command && typeof input.command === 'string') { + const cmd = input.command.trim(); + + if (cmd.startsWith('firecrawl search')) { + const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); + if (match) return `Searching "${match[1]}"`; + return 'Searching'; + } + if (cmd.startsWith('firecrawl scrape')) { + const m = cmd.match(/["'](https?:\/\/[^"']+)["']/) || cmd.match(/(https?:\/\/\S+)/); + if (m) return `Scraping ${m[1]}`; + return null; + } + if (cmd.startsWith('firecrawl map')) { + const m = cmd.match(/["'](https?:\/\/[^"']+)["']/) || cmd.match(/(https?:\/\/\S+)/); + if (m) return `Mapping ${m[1]}`; + return null; + } + if (cmd.startsWith('firecrawl crawl')) { + const m = cmd.match(/["'](https?:\/\/[^"']+)["']/) || cmd.match(/(https?:\/\/\S+)/); + if (m) return `Crawling ${m[1]}`; + return null; + } + if (cmd.startsWith('firecrawl agent')) { + return 'Running extraction agent'; + } + return null; + } + + if (input?.path && typeof input.path === 'string') { + if ( + input.path.startsWith(sessionDir) && + call.title.toLowerCase().includes('write') + ) { + const basename = input.path.split('/').pop() || input.path; + return `Writing ${basename}`; + } + return null; + } + + return null; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +export interface TUIHandle { + /** Write agent text above the status bar */ + writeText: (text: string) => void; + /** Register a new tool call */ + onToolCall: (call: ToolCallInfo) => void; + /** Update tool call status */ + onToolCallUpdate: (call: ToolCallInfo) => void; + /** Add credits */ + addCredits: (n: number) => void; + /** Unmount the TUI (call between turns for user input) */ + unmount: () => void; + /** Remount the TUI (call after user input to resume) */ + remount: () => void; +} + +export function startTUI(opts: { + sessionId: string; + agentName: string; + outputFormat: string; + sessionDir: string; +}): TUIHandle { + const stateRef: { current: TUIState } = { + current: { + toolCalls: [], + credits: 0, + startedAt: Date.now(), + sessionId: opts.sessionId, + agentName: opts.agentName, + outputFormat: opts.outputFormat, + rerender: null, + }, + }; + + let inkInstance = render(); + + function update() { + if (stateRef.current.rerender) stateRef.current.rerender(); + } + + return { + writeText(text: string) { + // Write text above ink's managed area + process.stdout.write(text); + }, + + onToolCall(call: ToolCallInfo) { + const label = describeCall(call, opts.sessionDir); + if (!label) return; + stateRef.current.toolCalls.push({ + id: call.id, + label, + status: 'active', + startedAt: Date.now(), + }); + update(); + }, + + onToolCallUpdate(call: ToolCallInfo) { + const existing = stateRef.current.toolCalls.find((t) => t.id === call.id); + if (!existing) return; + if (call.status === 'completed') existing.status = 'done'; + else if (call.status === 'errored') existing.status = 'error'; + update(); + }, + + addCredits(n: number) { + stateRef.current.credits += n; + update(); + }, + + unmount() { + inkInstance.unmount(); + }, + + remount() { + // Reset tool calls for the new turn, keep credits and timer + stateRef.current.toolCalls = []; + inkInstance = render(); + }, + }; +} diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 11d3822..44e836d 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -9,6 +9,7 @@ import { type ACPAgent, detectAgents } from '../acp/registry'; import { connectToAgent, type ToolCallInfo } from '../acp/client'; +import { startTUI, type TUIHandle } from '../acp/tui'; import { createSession, getSessionDir, @@ -429,12 +430,20 @@ export async function runInteractiveAgent(options: { `\n🔥 Loading ${selectedAgent.displayName} via Agent Client Protocol...\n` ); + // Start TUI + const sessionDir = getSessionDir(session.id); + const tui = startTUI({ + sessionId: session.id, + agentName: selectedAgent.displayName, + outputFormat: format, + sessionDir, + }); + // Handle Ctrl+C gracefully let agent: Awaited> | null = null; - const callbacks = buildCallbacks(getSessionDir(session.id)); const handleInterrupt = () => { - callbacks.cleanup(); + tui.unmount(); process.stderr.write('\nInterrupted.\n'); if (agent) { agent.cancel().catch(() => {}); @@ -448,14 +457,21 @@ export async function runInteractiveAgent(options: { agent = await connectToAgent({ bin: selectedAgent.bin, systemPrompt, - callbacks, + callbacks: { + onText: (text) => tui.writeText(text), + onToolCall: (call) => tui.onToolCall(call), + onToolCallUpdate: (call) => tui.onToolCallUpdate(call), + }, }); // ── Conversation loop ───────────────────────────────────────────────── let currentMessage = userMessage; while (true) { const result = await agent.prompt(currentMessage); - process.stdout.write('\n\n'); + + // Unmount TUI for user input + tui.unmount(); + process.stdout.write('\n'); // If the agent stopped for a reason other than end_turn, break if (result.stopReason !== 'end_turn') { @@ -482,13 +498,15 @@ export async function runInteractiveAgent(options: { break; } + // Remount TUI for next turn + tui.remount(); currentMessage = followUp; } } catch (error) { + tui.unmount(); console.error('\nError:', error instanceof Error ? error.message : error); process.exit(1); } finally { - callbacks.cleanup(); process.removeListener('SIGINT', handleInterrupt); if (agent) agent.close(); } diff --git a/tsconfig.json b/tsconfig.json index b4a3f82..805ddfa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "module": "commonjs", + "module": "preserve", "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -12,9 +12,10 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, - "types": ["node"] + "types": ["node"], + "jsx": "react-jsx" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] From e4dc8cb6ec0a843b15a0b89fd727958807aaeefd Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:15:34 -0400 Subject: [PATCH 11/63] replace ink with lightweight ANSI TUI ink/yoga requires top-level await which breaks tsx/commonjs. replaced with zero-dependency ANSI status bar: - pinned to bottom of terminal via scroll region - animated spinner when operations are active - shows: session | agent | credits | elapsed | format - pauses for user input, resumes for next turn - no npm dependencies added --- package.json | 5 +- pnpm-lock.yaml | 287 ------------------------------ src/acp/tui.ts | 247 +++++++++++++++++++++++++ src/acp/tui.tsx | 286 ----------------------------- src/commands/agent-interactive.ts | 15 +- tsconfig.json | 3 +- 6 files changed, 257 insertions(+), 586 deletions(-) create mode 100644 src/acp/tui.ts delete mode 100644 src/acp/tui.tsx diff --git a/package.json b/package.json index cbac49a..d7157e8 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "packageManager": "pnpm@10.12.1", "devDependencies": { "@types/node": "^20.0.0", - "@types/react": "^19.2.14", "husky": "^9.0.0", "lint-staged": "^15.0.0", "prettier": "^3.0.0", @@ -75,8 +74,6 @@ "@agentclientprotocol/sdk": "^0.17.0", "@inquirer/prompts": "^8.2.1", "@mendable/firecrawl-js": "4.17.0", - "commander": "^14.0.2", - "ink": "^6.8.0", - "react": "^19.2.4" + "commander": "^14.0.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20e2465..8438349 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,19 +20,10 @@ importers: commander: specifier: ^14.0.2 version: 14.0.2 - ink: - specifier: ^6.8.0 - version: 6.8.0(@types/react@19.2.14)(react@19.2.4) - react: - specifier: ^19.2.4 - version: 19.2.4 devDependencies: '@types/node': specifier: ^20.0.0 version: 20.19.27 - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 husky: specifier: ^9.0.0 version: 9.1.7 @@ -56,10 +47,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@alcalzone/ansi-tokenize@0.2.5': - resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} - engines: {node: '>=18'} - '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -497,9 +484,6 @@ packages: '@types/node@20.19.27': resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@vitest/expect@4.0.16': resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} @@ -533,10 +517,6 @@ packages: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -552,10 +532,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - auto-bind@5.0.1: - resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} @@ -578,14 +554,6 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} - - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -594,18 +562,10 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} - code-excerpt@4.0.0: - resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -621,17 +581,10 @@ packages: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} - convert-to-spaces@2.0.1: - resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -675,18 +628,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.45.1: - resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} - esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -752,10 +698,6 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} - engines: {node: '>=18'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -797,23 +739,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - - ink@6.8.0: - resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} - engines: {node: '>=20'} - peerDependencies: - '@types/react': '>=19.0.0' - react: '>=19.0.0' - react-devtools-core: '>=6.1.2' - peerDependenciesMeta: - '@types/react': - optional: true - react-devtools-core: - optional: true - is-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} engines: {node: '>=12'} @@ -822,11 +747,6 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-in-ci@2.0.0: - resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} - engines: {node: '>=20'} - hasBin: true - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -877,10 +797,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -908,10 +824,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -920,10 +832,6 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - patch-console@2.0.0: - resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -963,20 +871,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - react-reconciler@0.33.0: - resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} - engines: {node: '>=0.10.0'} - peerDependencies: - react: ^19.2.0 - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -992,9 +886,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1006,9 +897,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1021,18 +909,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1047,10 +927,6 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} - engines: {node: '>=20'} - strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -1059,14 +935,6 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - - terminal-size@4.0.1: - resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} - engines: {node: '>=18'} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1086,10 +954,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - type-fest@5.5.0: - resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} - engines: {node: '>=20'} - typescript-event-target@1.1.2: resolution: {integrity: sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==} @@ -1185,34 +1049,15 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@6.0.0: - resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} - engines: {node: '>=20'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} hasBin: true - yoga-layout@3.2.1: - resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} - zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -1227,11 +1072,6 @@ snapshots: dependencies: zod: 3.25.76 - '@alcalzone/ansi-tokenize@0.2.5': - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - '@esbuild/aix-ppc64@0.27.2': optional: true @@ -1531,10 +1371,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/react@19.2.14': - dependencies: - csstype: 3.2.3 - '@vitest/expect@4.0.16': dependencies: '@standard-schema/spec': 1.1.0 @@ -1578,10 +1414,6 @@ snapshots: dependencies: environment: 1.1.0 - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - ansi-regex@6.2.2: {} ansi-styles@6.2.3: {} @@ -1590,8 +1422,6 @@ snapshots: asynckit@0.4.0: {} - auto-bind@5.0.1: {} - axios@1.13.6: dependencies: follow-redirects: 1.15.11 @@ -1615,12 +1445,6 @@ snapshots: chardet@2.1.1: {} - cli-boxes@3.0.0: {} - - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -1630,17 +1454,8 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 - cli-truncate@5.2.0: - dependencies: - slice-ansi: 8.0.0 - string-width: 8.2.0 - cli-width@4.1.0: {} - code-excerpt@4.0.0: - dependencies: - convert-to-spaces: 2.0.1 - colorette@2.0.20: {} combined-stream@1.0.8: @@ -1651,16 +1466,12 @@ snapshots: commander@14.0.2: {} - convert-to-spaces@2.0.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - csstype@3.2.3: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -1694,8 +1505,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-toolkit@1.45.1: {} - esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -1725,8 +1534,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 - escape-string-regexp@2.0.0: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -1791,8 +1598,6 @@ snapshots: get-east-asian-width@1.4.0: {} - get-east-asian-width@1.5.0: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1833,50 +1638,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 - indent-string@5.0.0: {} - - ink@6.8.0(@types/react@19.2.14)(react@19.2.4): - dependencies: - '@alcalzone/ansi-tokenize': 0.2.5 - ansi-escapes: 7.3.0 - ansi-styles: 6.2.3 - auto-bind: 5.0.1 - chalk: 5.6.2 - cli-boxes: 3.0.0 - cli-cursor: 4.0.0 - cli-truncate: 5.2.0 - code-excerpt: 4.0.0 - es-toolkit: 1.45.1 - indent-string: 5.0.0 - is-in-ci: 2.0.0 - patch-console: 2.0.0 - react: 19.2.4 - react-reconciler: 0.33.0(react@19.2.4) - scheduler: 0.27.0 - signal-exit: 3.0.7 - slice-ansi: 8.0.0 - stack-utils: 2.0.6 - string-width: 8.2.0 - terminal-size: 4.0.1 - type-fest: 5.5.0 - widest-line: 6.0.0 - wrap-ansi: 9.0.2 - ws: 8.20.0 - yoga-layout: 3.2.1 - optionalDependencies: - '@types/react': 19.2.14 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - is-fullwidth-code-point@4.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.4.0 - is-in-ci@2.0.0: {} - is-number@7.0.0: {} is-stream@3.0.0: {} @@ -1936,8 +1703,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mimic-fn@2.1.0: {} - mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -1954,10 +1719,6 @@ snapshots: obug@2.1.1: {} - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -1966,8 +1727,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - patch-console@2.0.0: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -1992,18 +1751,6 @@ snapshots: proxy-from-env@1.1.0: {} - react-reconciler@0.33.0(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react@19.2.4: {} - - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -2044,8 +1791,6 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.27.0: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2054,8 +1799,6 @@ snapshots: siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} slice-ansi@5.0.0: @@ -2068,17 +1811,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - source-map-js@1.2.1: {} - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} std-env@3.10.0: {} @@ -2091,21 +1825,12 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 - string-width@8.2.0: - dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.1.2 - strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 strip-final-newline@3.0.0: {} - tagged-tag@1.0.0: {} - - terminal-size@4.0.1: {} - tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -2121,10 +1846,6 @@ snapshots: dependencies: is-number: 7.0.0 - type-fest@5.5.0: - dependencies: - tagged-tag: 1.0.0 - typescript-event-target@1.1.2: {} typescript@5.9.3: {} @@ -2190,22 +1911,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@6.0.0: - dependencies: - string-width: 8.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.1.2 - ws@8.20.0: {} - yaml@2.8.2: {} - yoga-layout@3.2.1: {} - zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/acp/tui.ts b/src/acp/tui.ts new file mode 100644 index 0000000..365d58a --- /dev/null +++ b/src/acp/tui.ts @@ -0,0 +1,247 @@ +/** + * Lightweight TUI for the Firecrawl agent — no dependencies. + * + * Shows: + * - Agent text streaming to stdout + * - Tool calls as start/done lines (only firecrawl ops) + * - A persistent status bar on the last line: session | agent | credits | time | format + */ + +import type { ToolCallInfo } from './client'; + +// ─── ANSI helpers ─────────────────────────────────────────────────────────── + +const DIM = '\x1b[2m'; +const RESET = '\x1b[0m'; +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const CYAN = '\x1b[36m'; +const YELLOW = '\x1b[33m'; +const BOLD = '\x1b[1m'; +const SAVE = '\x1b[s'; +const RESTORE = '\x1b[u'; +const CLEAR_LINE = '\x1b[2K'; + +// ─── Tool call label extraction ───────────────────────────────────────────── + +function extractUrl(cmd: string, prefix: string): string | null { + const quoted = cmd.match( + new RegExp(`${prefix}\\s+["'](https?://[^"']+)["']`) + ); + if (quoted) return quoted[1]; + const parts = cmd.replace(new RegExp(`^${prefix}\\s*`), '').split(/\s+/); + for (const part of parts) { + const clean = part.replace(/^["']|["']$/g, ''); + if (clean.startsWith('http')) return clean; + } + return null; +} + +function describeCall(call: ToolCallInfo, sessionDir: string): string | null { + const input = call.rawInput as Record | undefined; + + if (input?.command && typeof input.command === 'string') { + const cmd = input.command.trim(); + + if (cmd.startsWith('firecrawl search')) { + const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); + if (match) return `Searching "${match[1]}"`; + return 'Searching'; + } + if (cmd.startsWith('firecrawl scrape')) { + const url = extractUrl(cmd, 'firecrawl scrape'); + return url ? `Scraping ${url}` : null; + } + if (cmd.startsWith('firecrawl map')) { + const url = extractUrl(cmd, 'firecrawl map'); + return url ? `Mapping ${url}` : null; + } + if (cmd.startsWith('firecrawl crawl')) { + const url = extractUrl(cmd, 'firecrawl crawl'); + return url ? `Crawling ${url}` : null; + } + if (cmd.startsWith('firecrawl agent')) { + return 'Running extraction agent'; + } + return null; + } + + if (input?.path && typeof input.path === 'string') { + if ( + input.path.startsWith(sessionDir) && + call.title.toLowerCase().includes('write') + ) { + const basename = input.path.split('/').pop() || input.path; + return `Writing ${basename}`; + } + return null; + } + + return null; +} + +// ─── Status bar ───────────────────────────────────────────────────────────── + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +interface TUIState { + pending: Map; + credits: number; + startedAt: number; + sessionId: string; + agentName: string; + format: string; + sessionDir: string; + spinnerFrame: number; + statusVisible: boolean; + interval: ReturnType | null; +} + +function formatStatusBar(state: TUIState): string { + const elapsed = Math.round((Date.now() - state.startedAt) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + const fmt = state.format.toUpperCase(); + const active = state.pending.size; + + const spinner = + active > 0 ? `${YELLOW}${SPINNER[state.spinnerFrame]}${RESET} ` : ''; + + return ( + `${DIM}─${RESET} ` + + `${spinner}` + + `${BOLD}${state.sessionId}${RESET}` + + `${DIM} · ${RESET}${state.agentName}` + + `${DIM} · ${RESET}${CYAN}${state.credits} credits${RESET}` + + `${DIM} · ${RESET}${time}` + + `${DIM} · ${RESET}${YELLOW}${fmt}${RESET}` + + (active > 0 ? `${DIM} · ${RESET}${active} active` : '') + ); +} + +function writeStatusBar(state: TUIState): void { + // Move to bottom, write status, restore cursor + const rows = process.stdout.rows || 24; + process.stderr.write( + `${SAVE}\x1b[${rows};0H${CLEAR_LINE}${formatStatusBar(state)}${RESTORE}` + ); + state.statusVisible = true; +} + +function clearStatusBar(state: TUIState): void { + if (!state.statusVisible) return; + const rows = process.stdout.rows || 24; + process.stderr.write(`${SAVE}\x1b[${rows};0H${CLEAR_LINE}${RESTORE}`); + state.statusVisible = false; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +export interface TUIHandle { + onText: (text: string) => void; + onToolCall: (call: ToolCallInfo) => void; + onToolCallUpdate: (call: ToolCallInfo) => void; + addCredits: (n: number) => void; + /** Call before user input prompts */ + pause: () => void; + /** Call after user input to resume */ + resume: () => void; + /** Final cleanup */ + cleanup: () => void; +} + +export function startTUI(opts: { + sessionId: string; + agentName: string; + format: string; + sessionDir: string; +}): TUIHandle { + const state: TUIState = { + pending: new Map(), + credits: 0, + startedAt: Date.now(), + sessionId: opts.sessionId, + agentName: opts.agentName, + format: opts.format, + sessionDir: opts.sessionDir, + spinnerFrame: 0, + statusVisible: false, + interval: null, + }; + + // Reserve bottom line by setting scroll region + const rows = process.stdout.rows || 24; + process.stderr.write(`\x1b[1;${rows - 1}r`); // scroll region = all but last line + + // Start status bar ticker + state.interval = setInterval(() => { + state.spinnerFrame = (state.spinnerFrame + 1) % SPINNER.length; + writeStatusBar(state); + }, 80); + + // Initial render + writeStatusBar(state); + + return { + onText(text: string) { + process.stdout.write(text); + }, + + onToolCall(call: ToolCallInfo) { + const label = describeCall(call, state.sessionDir); + if (!label) return; + state.pending.set(call.id, label); + process.stderr.write(` ${DIM}·${RESET} ${label}\n`); + }, + + onToolCallUpdate(call: ToolCallInfo) { + if (!state.pending.has(call.id)) return; + const label = state.pending.get(call.id)!; + if (call.status === 'completed' || call.status === 'errored') { + state.pending.delete(call.id); + const icon = + call.status === 'completed' ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`; + process.stderr.write(` ${icon} ${label}\n`); + } + }, + + addCredits(n: number) { + state.credits += n; + }, + + pause() { + // Stop ticker and clear status bar for user input + if (state.interval) { + clearInterval(state.interval); + state.interval = null; + } + clearStatusBar(state); + // Reset scroll region to full terminal + const r = process.stdout.rows || 24; + process.stderr.write(`\x1b[1;${r}r`); + }, + + resume() { + // Re-reserve bottom line and restart ticker + const r = process.stdout.rows || 24; + process.stderr.write(`\x1b[1;${r - 1}r`); + state.interval = setInterval(() => { + state.spinnerFrame = (state.spinnerFrame + 1) % SPINNER.length; + writeStatusBar(state); + }, 80); + writeStatusBar(state); + }, + + cleanup() { + if (state.interval) { + clearInterval(state.interval); + state.interval = null; + } + clearStatusBar(state); + // Reset scroll region + const r = process.stdout.rows || 24; + process.stderr.write(`\x1b[1;${r}r`); + }, + }; +} diff --git a/src/acp/tui.tsx b/src/acp/tui.tsx deleted file mode 100644 index ce88c89..0000000 --- a/src/acp/tui.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Firecrawl Agent TUI — ink-based terminal UI for active turns. - * - * Renders a status bar while the agent is working, with credits, - * elapsed time, and active tool calls. Unmounts between turns - * so inquirer prompts work normally. - */ - -import React, { useState, useEffect } from 'react'; -import { render, Box, Text, Static } from 'ink'; -import type { ToolCallInfo } from './client.js'; - -// ─── Types ────────────────────────────────────────────────────────────────── - -interface ToolCallDisplay { - id: string; - label: string; - status: 'active' | 'done' | 'error'; - startedAt: number; -} - -// ─── Spinner ──────────────────────────────────────────────────────────────── - -const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -function Spinner() { - const [frame, setFrame] = useState(0); - useEffect(() => { - const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80); - return () => clearInterval(id); - }, []); - return {FRAMES[frame]}; -} - -// ─── Status Bar ───────────────────────────────────────────────────────────── - -function StatusBar({ - sessionId, - agentName, - credits, - startedAt, - outputFormat, -}: { - sessionId: string; - agentName: string; - credits: number; - startedAt: number; - outputFormat: string; -}) { - const [elapsed, setElapsed] = useState(0); - useEffect(() => { - const id = setInterval( - () => setElapsed(Math.round((Date.now() - startedAt) / 1000)), - 1000 - ); - return () => clearInterval(id); - }, [startedAt]); - - const mins = Math.floor(elapsed / 60); - const secs = elapsed % 60; - const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; - - return ( - - {sessionId} - | - {agentName} - | - {credits} credits - | - {time} - | - {outputFormat.toUpperCase()} - - ); -} - -// ─── Tool Call Lines ──────────────────────────────────────────────────────── - -function ToolCallLine({ call }: { call: ToolCallDisplay }) { - return ( - - - {call.status === 'active' ? ( - - ) : call.status === 'done' ? ( - - ) : ( - - )} - {call.label} - - ); -} - -// ─── Main App ─────────────────────────────────────────────────────────────── - -function App({ - stateRef, -}: { - stateRef: { current: TUIState }; -}) { - const [, setTick] = useState(0); - - useEffect(() => { - stateRef.current.rerender = () => setTick((t) => t + 1); - return () => { - stateRef.current.rerender = null; - }; - }, []); - - const s = stateRef.current; - - // Completed and active tool calls - const completed = s.toolCalls.filter((t) => t.status !== 'active'); - const active = s.toolCalls.filter((t) => t.status === 'active'); - - return ( - - {/* Completed tool calls — static, won't re-render */} - - {(call) => } - - - {/* Active tool calls — animated spinners */} - {active.map((call) => ( - - ))} - - {/* Status bar */} - - - ); -} - -// ─── State + Controller ───────────────────────────────────────────────────── - -interface TUIState { - toolCalls: ToolCallDisplay[]; - credits: number; - startedAt: number; - sessionId: string; - agentName: string; - outputFormat: string; - rerender: (() => void) | null; -} - -/** Extract a display label from a tool call, or null to hide it. */ -function describeCall( - call: ToolCallInfo, - sessionDir: string -): string | null { - const input = call.rawInput as Record | undefined; - - if (input?.command && typeof input.command === 'string') { - const cmd = input.command.trim(); - - if (cmd.startsWith('firecrawl search')) { - const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); - if (match) return `Searching "${match[1]}"`; - return 'Searching'; - } - if (cmd.startsWith('firecrawl scrape')) { - const m = cmd.match(/["'](https?:\/\/[^"']+)["']/) || cmd.match(/(https?:\/\/\S+)/); - if (m) return `Scraping ${m[1]}`; - return null; - } - if (cmd.startsWith('firecrawl map')) { - const m = cmd.match(/["'](https?:\/\/[^"']+)["']/) || cmd.match(/(https?:\/\/\S+)/); - if (m) return `Mapping ${m[1]}`; - return null; - } - if (cmd.startsWith('firecrawl crawl')) { - const m = cmd.match(/["'](https?:\/\/[^"']+)["']/) || cmd.match(/(https?:\/\/\S+)/); - if (m) return `Crawling ${m[1]}`; - return null; - } - if (cmd.startsWith('firecrawl agent')) { - return 'Running extraction agent'; - } - return null; - } - - if (input?.path && typeof input.path === 'string') { - if ( - input.path.startsWith(sessionDir) && - call.title.toLowerCase().includes('write') - ) { - const basename = input.path.split('/').pop() || input.path; - return `Writing ${basename}`; - } - return null; - } - - return null; -} - -// ─── Public API ───────────────────────────────────────────────────────────── - -export interface TUIHandle { - /** Write agent text above the status bar */ - writeText: (text: string) => void; - /** Register a new tool call */ - onToolCall: (call: ToolCallInfo) => void; - /** Update tool call status */ - onToolCallUpdate: (call: ToolCallInfo) => void; - /** Add credits */ - addCredits: (n: number) => void; - /** Unmount the TUI (call between turns for user input) */ - unmount: () => void; - /** Remount the TUI (call after user input to resume) */ - remount: () => void; -} - -export function startTUI(opts: { - sessionId: string; - agentName: string; - outputFormat: string; - sessionDir: string; -}): TUIHandle { - const stateRef: { current: TUIState } = { - current: { - toolCalls: [], - credits: 0, - startedAt: Date.now(), - sessionId: opts.sessionId, - agentName: opts.agentName, - outputFormat: opts.outputFormat, - rerender: null, - }, - }; - - let inkInstance = render(); - - function update() { - if (stateRef.current.rerender) stateRef.current.rerender(); - } - - return { - writeText(text: string) { - // Write text above ink's managed area - process.stdout.write(text); - }, - - onToolCall(call: ToolCallInfo) { - const label = describeCall(call, opts.sessionDir); - if (!label) return; - stateRef.current.toolCalls.push({ - id: call.id, - label, - status: 'active', - startedAt: Date.now(), - }); - update(); - }, - - onToolCallUpdate(call: ToolCallInfo) { - const existing = stateRef.current.toolCalls.find((t) => t.id === call.id); - if (!existing) return; - if (call.status === 'completed') existing.status = 'done'; - else if (call.status === 'errored') existing.status = 'error'; - update(); - }, - - addCredits(n: number) { - stateRef.current.credits += n; - update(); - }, - - unmount() { - inkInstance.unmount(); - }, - - remount() { - // Reset tool calls for the new turn, keep credits and timer - stateRef.current.toolCalls = []; - inkInstance = render(); - }, - }; -} diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 44e836d..71bd51f 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -9,7 +9,7 @@ import { type ACPAgent, detectAgents } from '../acp/registry'; import { connectToAgent, type ToolCallInfo } from '../acp/client'; -import { startTUI, type TUIHandle } from '../acp/tui'; +import { startTUI } from '../acp/tui'; import { createSession, getSessionDir, @@ -435,7 +435,7 @@ export async function runInteractiveAgent(options: { const tui = startTUI({ sessionId: session.id, agentName: selectedAgent.displayName, - outputFormat: format, + format, sessionDir, }); @@ -443,7 +443,7 @@ export async function runInteractiveAgent(options: { let agent: Awaited> | null = null; const handleInterrupt = () => { - tui.unmount(); + tui.cleanup(); process.stderr.write('\nInterrupted.\n'); if (agent) { agent.cancel().catch(() => {}); @@ -458,7 +458,7 @@ export async function runInteractiveAgent(options: { bin: selectedAgent.bin, systemPrompt, callbacks: { - onText: (text) => tui.writeText(text), + onText: (text) => tui.onText(text), onToolCall: (call) => tui.onToolCall(call), onToolCallUpdate: (call) => tui.onToolCallUpdate(call), }, @@ -470,7 +470,7 @@ export async function runInteractiveAgent(options: { const result = await agent.prompt(currentMessage); // Unmount TUI for user input - tui.unmount(); + tui.pause(); process.stdout.write('\n'); // If the agent stopped for a reason other than end_turn, break @@ -499,14 +499,15 @@ export async function runInteractiveAgent(options: { } // Remount TUI for next turn - tui.remount(); + tui.resume(); currentMessage = followUp; } } catch (error) { - tui.unmount(); + tui.cleanup(); console.error('\nError:', error instanceof Error ? error.message : error); process.exit(1); } finally { + tui.cleanup(); process.removeListener('SIGINT', handleInterrupt); if (agent) agent.close(); } diff --git a/tsconfig.json b/tsconfig.json index 805ddfa..ab09f5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,8 +14,7 @@ "sourceMap": true, "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, - "types": ["node"], - "jsx": "react-jsx" + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] From 72cb7ac2919bbf1e935a341579d25a36609c6e6b Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:18:28 -0400 Subject: [PATCH 12/63] simplify TUI to inline terminal flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no scroll regions or ANSI cursor tricks. status prints inline at natural breakpoints (between turns, on completion). works in any terminal, pipeable, agent-friendly. · Scraping forbes.com/lists/ai50 ✓ Scraping forbes.com/lists/ai50 ── xy90n9zy · Claude Code · 12 credits · 34s · CSV --- src/acp/tui.ts | 176 ++++++++++++------------------------------------- 1 file changed, 43 insertions(+), 133 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 365d58a..6a39971 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -1,26 +1,18 @@ /** - * Lightweight TUI for the Firecrawl agent — no dependencies. + * Firecrawl agent display — inline terminal output, no ANSI tricks. * - * Shows: - * - Agent text streaming to stdout - * - Tool calls as start/done lines (only firecrawl ops) - * - A persistent status bar on the last line: session | agent | credits | time | format + * Prints tool calls and status summaries in the normal terminal flow. + * Works in any terminal, pipeable, agent-friendly. */ import type { ToolCallInfo } from './client'; -// ─── ANSI helpers ─────────────────────────────────────────────────────────── +// ─── Dim helper (only if TTY) ─────────────────────────────────────────────── -const DIM = '\x1b[2m'; -const RESET = '\x1b[0m'; -const GREEN = '\x1b[32m'; -const RED = '\x1b[31m'; -const CYAN = '\x1b[36m'; -const YELLOW = '\x1b[33m'; -const BOLD = '\x1b[1m'; -const SAVE = '\x1b[s'; -const RESTORE = '\x1b[u'; -const CLEAR_LINE = '\x1b[2K'; +const isTTY = process.stderr.isTTY; +const dim = (s: string) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s); +const green = (s: string) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s); +const red = (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s); // ─── Tool call label extraction ───────────────────────────────────────────── @@ -80,62 +72,6 @@ function describeCall(call: ToolCallInfo, sessionDir: string): string | null { return null; } -// ─── Status bar ───────────────────────────────────────────────────────────── - -const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -interface TUIState { - pending: Map; - credits: number; - startedAt: number; - sessionId: string; - agentName: string; - format: string; - sessionDir: string; - spinnerFrame: number; - statusVisible: boolean; - interval: ReturnType | null; -} - -function formatStatusBar(state: TUIState): string { - const elapsed = Math.round((Date.now() - state.startedAt) / 1000); - const mins = Math.floor(elapsed / 60); - const secs = elapsed % 60; - const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; - const fmt = state.format.toUpperCase(); - const active = state.pending.size; - - const spinner = - active > 0 ? `${YELLOW}${SPINNER[state.spinnerFrame]}${RESET} ` : ''; - - return ( - `${DIM}─${RESET} ` + - `${spinner}` + - `${BOLD}${state.sessionId}${RESET}` + - `${DIM} · ${RESET}${state.agentName}` + - `${DIM} · ${RESET}${CYAN}${state.credits} credits${RESET}` + - `${DIM} · ${RESET}${time}` + - `${DIM} · ${RESET}${YELLOW}${fmt}${RESET}` + - (active > 0 ? `${DIM} · ${RESET}${active} active` : '') - ); -} - -function writeStatusBar(state: TUIState): void { - // Move to bottom, write status, restore cursor - const rows = process.stdout.rows || 24; - process.stderr.write( - `${SAVE}\x1b[${rows};0H${CLEAR_LINE}${formatStatusBar(state)}${RESTORE}` - ); - state.statusVisible = true; -} - -function clearStatusBar(state: TUIState): void { - if (!state.statusVisible) return; - const rows = process.stdout.rows || 24; - process.stderr.write(`${SAVE}\x1b[${rows};0H${CLEAR_LINE}${RESTORE}`); - state.statusVisible = false; -} - // ─── Public API ───────────────────────────────────────────────────────────── export interface TUIHandle { @@ -143,11 +79,10 @@ export interface TUIHandle { onToolCall: (call: ToolCallInfo) => void; onToolCallUpdate: (call: ToolCallInfo) => void; addCredits: (n: number) => void; - /** Call before user input prompts */ + /** Print a status summary line */ + printStatus: () => void; pause: () => void; - /** Call after user input to resume */ resume: () => void; - /** Final cleanup */ cleanup: () => void; } @@ -157,31 +92,23 @@ export function startTUI(opts: { format: string; sessionDir: string; }): TUIHandle { - const state: TUIState = { - pending: new Map(), - credits: 0, - startedAt: Date.now(), - sessionId: opts.sessionId, - agentName: opts.agentName, - format: opts.format, - sessionDir: opts.sessionDir, - spinnerFrame: 0, - statusVisible: false, - interval: null, - }; - - // Reserve bottom line by setting scroll region - const rows = process.stdout.rows || 24; - process.stderr.write(`\x1b[1;${rows - 1}r`); // scroll region = all but last line - - // Start status bar ticker - state.interval = setInterval(() => { - state.spinnerFrame = (state.spinnerFrame + 1) % SPINNER.length; - writeStatusBar(state); - }, 80); + const pending = new Map(); + let credits = 0; + const startedAt = Date.now(); + + function elapsed(): string { + const secs = Math.round((Date.now() - startedAt) / 1000); + const m = Math.floor(secs / 60); + const s = secs % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; + } - // Initial render - writeStatusBar(state); + function statusLine(): string { + const fmt = opts.format.toUpperCase(); + return dim( + `── ${opts.sessionId} · ${opts.agentName} · ${credits} credits · ${elapsed()} · ${fmt}` + ); + } return { onText(text: string) { @@ -189,59 +116,42 @@ export function startTUI(opts: { }, onToolCall(call: ToolCallInfo) { - const label = describeCall(call, state.sessionDir); + const label = describeCall(call, opts.sessionDir); if (!label) return; - state.pending.set(call.id, label); - process.stderr.write(` ${DIM}·${RESET} ${label}\n`); + pending.set(call.id, label); + process.stderr.write(` ${dim('·')} ${label}\n`); }, onToolCallUpdate(call: ToolCallInfo) { - if (!state.pending.has(call.id)) return; - const label = state.pending.get(call.id)!; + if (!pending.has(call.id)) return; + const label = pending.get(call.id)!; if (call.status === 'completed' || call.status === 'errored') { - state.pending.delete(call.id); - const icon = - call.status === 'completed' ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`; + pending.delete(call.id); + const icon = call.status === 'completed' ? green('✓') : red('✗'); process.stderr.write(` ${icon} ${label}\n`); } }, addCredits(n: number) { - state.credits += n; + credits += n; + }, + + printStatus() { + process.stderr.write(`\n${statusLine()}\n\n`); }, pause() { - // Stop ticker and clear status bar for user input - if (state.interval) { - clearInterval(state.interval); - state.interval = null; - } - clearStatusBar(state); - // Reset scroll region to full terminal - const r = process.stdout.rows || 24; - process.stderr.write(`\x1b[1;${r}r`); + // Print status before handing to user input + process.stderr.write(`\n${statusLine()}\n`); }, resume() { - // Re-reserve bottom line and restart ticker - const r = process.stdout.rows || 24; - process.stderr.write(`\x1b[1;${r - 1}r`); - state.interval = setInterval(() => { - state.spinnerFrame = (state.spinnerFrame + 1) % SPINNER.length; - writeStatusBar(state); - }, 80); - writeStatusBar(state); + // Nothing to do — we print inline }, cleanup() { - if (state.interval) { - clearInterval(state.interval); - state.interval = null; - } - clearStatusBar(state); - // Reset scroll region - const r = process.stdout.rows || 24; - process.stderr.write(`\x1b[1;${r}r`); + // Final status + process.stderr.write(`\n${statusLine()}\n`); }, }; } From cf64d3ff0efa79d9b9933b8537d59360a828642d Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:21:10 -0400 Subject: [PATCH 13/63] show all agent activity, not just firecrawl ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit categorize every tool call into prominent (firecrawl ops, output writes) or background (processing, reading, spawning agents). prominent calls show start + done. background calls show once per label (deduplicated) in dim text so the user always knows the agent is working. · Searching "AI startups" ← prominent · Processing ← background (dim) · Spawning agents ← background (dim) · Scraping forbes.com/lists/ai50 ← prominent ✓ Scraping forbes.com/lists/ai50 · Reading sources ← background (dim) · Processing data ← background (dim) ✓ Writing output.csv --- src/acp/tui.ts | 124 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 94 insertions(+), 30 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 6a39971..4fdb746 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -1,20 +1,27 @@ /** * Firecrawl agent display — inline terminal output, no ANSI tricks. * - * Prints tool calls and status summaries in the normal terminal flow. + * Shows firecrawl operations prominently, background operations dimmed, + * and status summaries at natural breakpoints. * Works in any terminal, pipeable, agent-friendly. */ import type { ToolCallInfo } from './client'; -// ─── Dim helper (only if TTY) ─────────────────────────────────────────────── +// ─── Style helpers (only if TTY) ──────────────────────────────────────────── const isTTY = process.stderr.isTTY; const dim = (s: string) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s); const green = (s: string) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s); const red = (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s); +const cyan = (s: string) => (isTTY ? `\x1b[36m${s}\x1b[0m` : s); -// ─── Tool call label extraction ───────────────────────────────────────────── +// ─── Tool call categorization ─────────────────────────────────────────────── + +interface CallDescription { + label: string; + prominent: boolean; // firecrawl ops = prominent, background work = dimmed +} function extractUrl(cmd: string, prefix: string): string | null { const quoted = cmd.match( @@ -29,47 +36,87 @@ function extractUrl(cmd: string, prefix: string): string | null { return null; } -function describeCall(call: ToolCallInfo, sessionDir: string): string | null { +function describeCall( + call: ToolCallInfo, + sessionDir: string +): CallDescription | null { const input = call.rawInput as Record | undefined; + const title = call.title.toLowerCase(); + // ── Terminal / Bash commands ─────────────────────────────────────────── if (input?.command && typeof input.command === 'string') { const cmd = input.command.trim(); + // Firecrawl commands — prominent if (cmd.startsWith('firecrawl search')) { const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); - if (match) return `Searching "${match[1]}"`; - return 'Searching'; + return { + label: match ? `Searching "${match[1]}"` : 'Searching', + prominent: true, + }; } if (cmd.startsWith('firecrawl scrape')) { const url = extractUrl(cmd, 'firecrawl scrape'); - return url ? `Scraping ${url}` : null; + return url ? { label: `Scraping ${url}`, prominent: true } : null; } if (cmd.startsWith('firecrawl map')) { const url = extractUrl(cmd, 'firecrawl map'); - return url ? `Mapping ${url}` : null; + return url ? { label: `Mapping ${url}`, prominent: true } : null; } if (cmd.startsWith('firecrawl crawl')) { const url = extractUrl(cmd, 'firecrawl crawl'); - return url ? `Crawling ${url}` : null; + return url ? { label: `Crawling ${url}`, prominent: true } : null; } if (cmd.startsWith('firecrawl agent')) { - return 'Running extraction agent'; + return { label: 'Running extraction agent', prominent: true }; } - return null; + if (cmd.startsWith('firecrawl')) { + return { label: 'Running firecrawl', prominent: true }; + } + + // Python / processing scripts — background + if (cmd.includes('python')) { + return { label: 'Processing data', prominent: false }; + } + + // Writing to session dir — prominent + if (cmd.includes(sessionDir)) { + return { label: 'Writing output', prominent: true }; + } + + // Everything else is background + return { label: 'Processing', prominent: false }; } + // ── File operations ─────────────────────────────────────────────────── if (input?.path && typeof input.path === 'string') { - if ( - input.path.startsWith(sessionDir) && - call.title.toLowerCase().includes('write') - ) { + if (input.path.startsWith(sessionDir) && title.includes('write')) { const basename = input.path.split('/').pop() || input.path; - return `Writing ${basename}`; + return { label: `Writing ${basename}`, prominent: true }; + } + if (title.includes('read')) { + return { label: 'Reading sources', prominent: false }; } return null; } - return null; + // ── Agent/Task spawning ─────────────────────────────────────────────── + if (title.includes('task') || title.includes('agent')) { + return { label: 'Spawning agents', prominent: false }; + } + + // ── Tool search / setup ─────────────────────────────────────────────── + if (title.includes('toolsearch') || title.includes('tool')) { + return { label: 'Setting up tools', prominent: false }; + } + + // ── Search / grep ───────────────────────────────────────────────────── + if (title.includes('grep') || title.includes('search')) { + return { label: 'Analyzing data', prominent: false }; + } + + // ── Catch-all for anything else ─────────────────────────────────────── + return { label: 'Working', prominent: false }; } // ─── Public API ───────────────────────────────────────────────────────────── @@ -79,7 +126,6 @@ export interface TUIHandle { onToolCall: (call: ToolCallInfo) => void; onToolCallUpdate: (call: ToolCallInfo) => void; addCredits: (n: number) => void; - /** Print a status summary line */ printStatus: () => void; pause: () => void; resume: () => void; @@ -92,10 +138,13 @@ export function startTUI(opts: { format: string; sessionDir: string; }): TUIHandle { - const pending = new Map(); + const pending = new Map(); let credits = 0; const startedAt = Date.now(); + // Collapse repeated dim labels (avoid 10x "Processing" lines) + let lastDimLabel = ''; + function elapsed(): string { const secs = Math.round((Date.now() - startedAt) / 1000); const m = Math.floor(secs / 60); @@ -112,23 +161,39 @@ export function startTUI(opts: { return { onText(text: string) { + // Reset dim dedup when agent writes text (new section) + lastDimLabel = ''; process.stdout.write(text); }, onToolCall(call: ToolCallInfo) { - const label = describeCall(call, opts.sessionDir); - if (!label) return; - pending.set(call.id, label); - process.stderr.write(` ${dim('·')} ${label}\n`); + const desc = describeCall(call, opts.sessionDir); + if (!desc) return; + pending.set(call.id, desc); + + if (desc.prominent) { + // Firecrawl operations — always show + lastDimLabel = ''; + process.stderr.write(` ${dim('·')} ${desc.label}\n`); + } else { + // Background work — show once per label to avoid spam + if (desc.label !== lastDimLabel) { + lastDimLabel = desc.label; + process.stderr.write(` ${dim('· ' + desc.label)}\n`); + } + } }, onToolCallUpdate(call: ToolCallInfo) { - if (!pending.has(call.id)) return; - const label = pending.get(call.id)!; + const desc = pending.get(call.id); + if (!desc) return; if (call.status === 'completed' || call.status === 'errored') { pending.delete(call.id); - const icon = call.status === 'completed' ? green('✓') : red('✗'); - process.stderr.write(` ${icon} ${label}\n`); + // Only print done for prominent calls + if (desc.prominent) { + const icon = call.status === 'completed' ? green('✓') : red('✗'); + process.stderr.write(` ${icon} ${desc.label}\n`); + } } }, @@ -141,16 +206,15 @@ export function startTUI(opts: { }, pause() { - // Print status before handing to user input process.stderr.write(`\n${statusLine()}\n`); + lastDimLabel = ''; }, resume() { - // Nothing to do — we print inline + lastDimLabel = ''; }, cleanup() { - // Final status process.stderr.write(`\n${statusLine()}\n`); }, }; From b9dc3fc9d49ad7f5cc22392be5a542c3f0510166 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:25:09 -0400 Subject: [PATCH 14/63] deduplicate tool calls and clean up display - deduplicate by URL: scraping the same URL 5x shows one start line and one done line, not ten - hide background operations entirely (agent text provides context) - visual spacing between agent text and tool call blocks - reset dedup between conversation turns - status line at each turn boundary with credits + elapsed --- src/acp/tui.ts | 190 ++++++++++++++++++++++++++++--------------------- 1 file changed, 110 insertions(+), 80 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 4fdb746..39b584d 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -1,26 +1,39 @@ /** - * Firecrawl agent display — inline terminal output, no ANSI tricks. + * Firecrawl agent display — clean inline terminal output. * - * Shows firecrawl operations prominently, background operations dimmed, - * and status summaries at natural breakpoints. - * Works in any terminal, pipeable, agent-friendly. + * Design principles (inspired by Codex CLI): + * - Show firecrawl operations once, update status in place + * - Deduplicate repeated calls to the same URL + * - Group background work into single status lines + * - Visual separation between agent text and tool execution + * - Status summary at turn boundaries */ import type { ToolCallInfo } from './client'; -// ─── Style helpers (only if TTY) ──────────────────────────────────────────── +// ─── Style helpers (TTY-aware) ────────────────────────────────────────────── const isTTY = process.stderr.isTTY; const dim = (s: string) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s); const green = (s: string) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s); const red = (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s); -const cyan = (s: string) => (isTTY ? `\x1b[36m${s}\x1b[0m` : s); +const bold = (s: string) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s); // ─── Tool call categorization ─────────────────────────────────────────────── +type CallKind = + | 'search' + | 'scrape' + | 'map' + | 'crawl' + | 'extract' + | 'write' + | 'background'; + interface CallDescription { label: string; - prominent: boolean; // firecrawl ops = prominent, background work = dimmed + kind: CallKind; + dedupeKey: string; // for deduplication (e.g., URL) } function extractUrl(cmd: string, prefix: string): string | null { @@ -36,87 +49,80 @@ function extractUrl(cmd: string, prefix: string): string | null { return null; } -function describeCall( +function categorizeCall( call: ToolCallInfo, sessionDir: string ): CallDescription | null { const input = call.rawInput as Record | undefined; const title = call.title.toLowerCase(); - // ── Terminal / Bash commands ─────────────────────────────────────────── if (input?.command && typeof input.command === 'string') { const cmd = input.command.trim(); - // Firecrawl commands — prominent if (cmd.startsWith('firecrawl search')) { const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); + const query = match ? match[1] : 'web'; return { - label: match ? `Searching "${match[1]}"` : 'Searching', - prominent: true, + label: `Searching "${query}"`, + kind: 'search', + dedupeKey: `search:${query}`, }; } if (cmd.startsWith('firecrawl scrape')) { const url = extractUrl(cmd, 'firecrawl scrape'); - return url ? { label: `Scraping ${url}`, prominent: true } : null; + if (!url) return null; + return { + label: `Scraping ${url}`, + kind: 'scrape', + dedupeKey: `scrape:${url}`, + }; } if (cmd.startsWith('firecrawl map')) { const url = extractUrl(cmd, 'firecrawl map'); - return url ? { label: `Mapping ${url}`, prominent: true } : null; + if (!url) return null; + return { label: `Mapping ${url}`, kind: 'map', dedupeKey: `map:${url}` }; } if (cmd.startsWith('firecrawl crawl')) { const url = extractUrl(cmd, 'firecrawl crawl'); - return url ? { label: `Crawling ${url}`, prominent: true } : null; + if (!url) return null; + return { + label: `Crawling ${url}`, + kind: 'crawl', + dedupeKey: `crawl:${url}`, + }; } if (cmd.startsWith('firecrawl agent')) { - return { label: 'Running extraction agent', prominent: true }; - } - if (cmd.startsWith('firecrawl')) { - return { label: 'Running firecrawl', prominent: true }; - } - - // Python / processing scripts — background - if (cmd.includes('python')) { - return { label: 'Processing data', prominent: false }; + return { + label: 'Running extraction agent', + kind: 'extract', + dedupeKey: 'extract', + }; } - - // Writing to session dir — prominent if (cmd.includes(sessionDir)) { - return { label: 'Writing output', prominent: true }; + return { + label: 'Writing output', + kind: 'write', + dedupeKey: 'write-session', + }; } - - // Everything else is background - return { label: 'Processing', prominent: false }; + // All other commands are background + return null; } - // ── File operations ─────────────────────────────────────────────────── + // File writes to session dir if (input?.path && typeof input.path === 'string') { if (input.path.startsWith(sessionDir) && title.includes('write')) { const basename = input.path.split('/').pop() || input.path; - return { label: `Writing ${basename}`, prominent: true }; - } - if (title.includes('read')) { - return { label: 'Reading sources', prominent: false }; + return { + label: `Writing ${basename}`, + kind: 'write', + dedupeKey: `write:${basename}`, + }; } return null; } - // ── Agent/Task spawning ─────────────────────────────────────────────── - if (title.includes('task') || title.includes('agent')) { - return { label: 'Spawning agents', prominent: false }; - } - - // ── Tool search / setup ─────────────────────────────────────────────── - if (title.includes('toolsearch') || title.includes('tool')) { - return { label: 'Setting up tools', prominent: false }; - } - - // ── Search / grep ───────────────────────────────────────────────────── - if (title.includes('grep') || title.includes('search')) { - return { label: 'Analyzing data', prominent: false }; - } - - // ── Catch-all for anything else ─────────────────────────────────────── - return { label: 'Working', prominent: false }; + return null; } // ─── Public API ───────────────────────────────────────────────────────────── @@ -138,12 +144,17 @@ export function startTUI(opts: { format: string; sessionDir: string; }): TUIHandle { - const pending = new Map(); + // Track calls by ID → description + const calls = new Map(); + // Track which dedupe keys we've already printed (to avoid duplicate lines) + const printed = new Set(); + // Track completed dedupe keys + const completed = new Set(); + let credits = 0; const startedAt = Date.now(); - - // Collapse repeated dim labels (avoid 10x "Processing" lines) - let lastDimLabel = ''; + let lastOutputWasText = false; + let backgroundShown = false; function elapsed(): string { const secs = Math.round((Date.now() - startedAt) / 1000); @@ -159,41 +170,56 @@ export function startTUI(opts: { ); } + function ensureNewSection() { + if (lastOutputWasText) { + lastOutputWasText = false; + } + } + return { onText(text: string) { - // Reset dim dedup when agent writes text (new section) - lastDimLabel = ''; + // If we were showing tool calls and now getting text, add spacing + if (!lastOutputWasText && printed.size > 0) { + process.stdout.write('\n'); + } process.stdout.write(text); + lastOutputWasText = true; + backgroundShown = false; }, onToolCall(call: ToolCallInfo) { - const desc = describeCall(call, opts.sessionDir); - if (!desc) return; - pending.set(call.id, desc); - - if (desc.prominent) { - // Firecrawl operations — always show - lastDimLabel = ''; - process.stderr.write(` ${dim('·')} ${desc.label}\n`); - } else { - // Background work — show once per label to avoid spam - if (desc.label !== lastDimLabel) { - lastDimLabel = desc.label; - process.stderr.write(` ${dim('· ' + desc.label)}\n`); + const desc = categorizeCall(call, opts.sessionDir); + if (!desc) { + // Background work — show a single "Working..." if we haven't yet + if (!backgroundShown && lastOutputWasText) { + // Don't print anything for background — the agent text provides context } + return; } + + calls.set(call.id, desc); + + // Deduplicate: don't print if we already showed this exact operation + if (printed.has(desc.dedupeKey)) return; + printed.add(desc.dedupeKey); + + ensureNewSection(); + process.stderr.write(` ${dim('·')} ${desc.label}\n`); }, onToolCallUpdate(call: ToolCallInfo) { - const desc = pending.get(call.id); + const desc = calls.get(call.id); if (!desc) return; + if (call.status === 'completed' || call.status === 'errored') { - pending.delete(call.id); - // Only print done for prominent calls - if (desc.prominent) { - const icon = call.status === 'completed' ? green('✓') : red('✗'); - process.stderr.write(` ${icon} ${desc.label}\n`); - } + calls.delete(call.id); + + // Only print completion if we haven't already for this dedupe key + if (completed.has(desc.dedupeKey)) return; + completed.add(desc.dedupeKey); + + const icon = call.status === 'completed' ? green('✓') : red('✗'); + process.stderr.write(` ${icon} ${desc.label}\n`); } }, @@ -207,11 +233,15 @@ export function startTUI(opts: { pause() { process.stderr.write(`\n${statusLine()}\n`); - lastDimLabel = ''; + lastOutputWasText = false; }, resume() { - lastDimLabel = ''; + // Reset dedup for next turn — same URLs may be re-scraped intentionally + printed.clear(); + completed.clear(); + backgroundShown = false; + lastOutputWasText = false; }, cleanup() { From cf9c8d5097534fe9345168f69556de78554d6bc6 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:40:33 -0400 Subject: [PATCH 15/63] add pipe mode + rewrite system prompt for better UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two modes for running the agent: - ACP: structured protocol with progress tracking and session mgmt - Pipe: inject directly into Claude Code/Codex (full terminal, interactive) user picks mode after selecting the agent. pipe mode falls back to the original launchAgent() subprocess approach. also rewrites system prompt to: - work in clear phases (plan → discover → extract → write) - stop after plan phase and wait for user confirmation - report progress with numbers (found 4 sources, extracted 50 records) - use bullet points not tables (renders better in terminals) - keep user informed at every step --- src/commands/agent-interactive.ts | 158 +++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 45 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 71bd51f..ac33e86 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -19,7 +19,9 @@ import { import { FIRECRAWL_TOOLS_BLOCK, SUBAGENT_INSTRUCTIONS, + askPermissionMode, } from './experimental/shared'; +import { type Backend, BACKENDS, launchAgent } from './experimental/backends'; // ─── Suggestions ──────────────────────────────────────────────────────────── @@ -81,57 +83,87 @@ Each record object must have identical keys. Tell the user the file path and rec - Tell the user the file path and record count when done.`, }; - return `You are a data gathering agent powered by Firecrawl. You discover sources, extract structured records, and consolidate them into clean, importable datasets. + return `You are Firecrawl Agent — a data gathering tool that builds structured datasets from the web. -**CRITICAL: You are building a DATASET, not writing a report.** Think spreadsheet rows, not document sections. Every record must have the same fields. The output must be directly importable into a spreadsheet, database, or API. +You are running inside a CLI. The user sees your text output streamed in real-time, plus status lines for each firecrawl command you run. Structure your output for readability in a terminal. ${FIRECRAWL_TOOLS_BLOCK} -## Your Strategy - -### Phase 1: Schema Design -Before searching anything, analyze the user's request and determine: -1. What entity type are you collecting? (companies, people, products, events, etc.) -2. What fields/columns should each record have? -3. **IMPORTANT: Print the proposed schema to the user and ask them to confirm before proceeding.** Example: - "I'll collect these fields: \`name\`, \`funding\`, \`team_size\`, \`category\`, \`website\`, \`source_url\`. Look good? Or would you like to add/remove any fields?" -4. Wait for user confirmation. They may want to tweak the schema. - -### Phase 2: Source Discovery -- Use \`firecrawl search\` with multiple queries to find high-quality data sources. -- If seed URLs are provided, use \`firecrawl map\` to discover subpages. -- Identify 3-10 high-quality sources depending on request scope. - -### Phase 3: Parallel Extraction -Spawn parallel subagents — one per data source or source cluster. - -${SUBAGENT_INSTRUCTIONS} - -Each subagent should: -1. Scrape its assigned source(s) using \`firecrawl scrape \` or \`firecrawl scrape --format json\` -2. Extract records matching the confirmed schema -3. Return results as a JSON array of objects with consistent field names -4. Include \`source_url\` in every record for provenance - -### Phase 4: Consolidation -After all subagents return: -1. Merge all records into a single array -2. Deduplicate by a reasonable key (name + URL, or similar) -3. Normalize field values (consistent date formats, trim whitespace, etc.) -4. Fill missing fields with empty string (CSV) or null (JSON) — never omit fields -5. Write the final output file - -## Data Quality Rules -- Every record MUST have the exact same set of fields -- Never fabricate data — leave fields empty if not found -- Always include \`source_url\` for provenance -- Deduplicate records by a reasonable primary key -- Normalize values (consistent capitalization, date formats, etc.) - -## Output Format +## How You Work + +You work in clear phases. **STOP after each phase** and wait for user input before continuing. Do not rush ahead. The user is watching and may want to adjust. + +### Phase 1: Plan +Propose a schema (list fields as bullet points, not a table — tables render poorly in terminals) and a brief plan of what sources you'll check. Then STOP and wait. + +Example output: +\`\`\` +Fields: name, funding, team_size, product_url, category, source_url + +Plan: +1. Search for "top AI startups" lists +2. Scrape Forbes AI 50, TechCrunch, TopStartups.io +3. Cross-reference and deduplicate +4. Write ${opts.format.toUpperCase()} with ~50 records + +Shall I proceed? +\`\`\` + +### Phase 2: Discover Sources +Search for relevant data sources. After finding them, tell the user what you found: + +\`\`\` +Found 4 good sources: +- forbes.com/lists/ai50 (50 companies) +- techcrunch.com/... (49 companies) +- topstartups.io (160+ companies) +- failory.com/... (unicorns list) + +Scraping now... +\`\`\` + +### Phase 3: Extract Data +Scrape each source. As you extract data, report progress: + +\`\`\` +Extracted 50 from Forbes AI 50 +Extracted 49 from TechCrunch +Extracted 82 from TopStartups.io (pages 1-3) + +Total raw records: 181 +\`\`\` + +### Phase 4: Write Output +Deduplicate, normalize, and write the file. Report the result: + +\`\`\` +Deduplicated: 181 → 127 unique companies +Written to: ${opts.sessionDir}/output.${opts.format === 'csv' ? 'csv' : opts.format === 'json' ? 'json' : 'md'} + +Top entries: +- OpenAI ($11.3B funding) +- Anthropic ($7.3B funding) +- ... +\`\`\` + +## Output Rules + ${outputInstructions[opts.format] || outputInstructions.json} -Start by analyzing the request and proposing a schema.`; +## Data Quality + +- Every record has the same fields. No exceptions. +- Never fabricate data — empty string for missing values. +- Include \`source_url\` in every record. +- Deduplicate by name (case-insensitive). + +## Terminal Output Style + +- Use short paragraphs. No walls of text. +- Use bullet points and code-style backticks for field names. +- Do NOT use markdown tables — they render poorly in terminals. Use bullet points or plain text. +- Report numbers: "Found 4 sources", "Extracted 50 records", "Deduplicated 181 → 127". +- Keep the user informed at every step — they should never wonder what you're doing.`; } // ─── Tool call display ────────────────────────────────────────────────────── @@ -366,6 +398,33 @@ export async function runInteractiveAgent(options: { selectedAgent = agents.find((a) => a.name === chosen)!; } + // ── Select mode ───────────────────────────────────────────────────────── + // Check if this agent also has a direct CLI (pipe mode) + const pipeBackends: Record = { + claude: 'claude', + codex: 'codex', + opencode: 'opencode', + }; + const hasPipeMode = selectedAgent.name in pipeBackends; + + let useACP = true; + if (hasPipeMode) { + const mode = await select({ + message: 'How should the agent run?', + choices: [ + { + name: 'ACP (structured — shows progress, credits, session tracking)', + value: 'acp', + }, + { + name: `Pipe into ${selectedAgent.displayName} (full terminal, interactive)`, + value: 'pipe', + }, + ], + }); + useACP = mode === 'acp'; + } + // ── Gather prompt ─────────────────────────────────────────────────────── const promptChoice = await select({ message: 'What data do you want to gather?', @@ -425,6 +484,15 @@ export async function runInteractiveAgent(options: { if (urls.trim()) parts.push(`Start from these URLs: ${urls}`); const userMessage = parts.join('. ') + '.'; + // ── Pipe mode — launch directly into the agent CLI ─────────────────── + if (!useACP && hasPipeMode) { + const backend = pipeBackends[selectedAgent.name]; + const skipPermissions = options.yes || (await askPermissionMode(backend)); + console.log(`\nLaunching ${selectedAgent.displayName}...\n`); + launchAgent(backend, systemPrompt, userMessage, skipPermissions); + return; + } + // ── Connect via ACP ─────────────────────────────────────────────────── console.log( `\n🔥 Loading ${selectedAgent.displayName} via Agent Client Protocol...\n` From 1654f118603eb7c279ea501f4211c9c6c8794a30 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:42:08 -0400 Subject: [PATCH 16/63] add open file/folder prompt at session end after the conversation ends, show a menu with all session output files (with sizes) plus the session folder. selecting one opens it with the system default handler (open/xdg-open/start). --- src/commands/agent-interactive.ts | 70 ++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index ac33e86..ffa3a43 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -278,6 +278,69 @@ function buildCallbacks(sessionDir: string): { }; } +// ─── Session end ──────────────────────────────────────────────────────────── + +async function showSessionEnd( + sessionId: string, + outputPath: string, + sessionDir: string +): Promise { + const { select } = await import('@inquirer/prompts'); + const fs = await import('fs'); + const { execSync } = await import('child_process'); + + console.log(`\nSession ${sessionId} saved.`); + + // Check what files exist in the session dir + const files: string[] = []; + if (fs.existsSync(sessionDir)) { + for (const f of fs.readdirSync(sessionDir)) { + if (f !== 'session.json') { + files.push(f); + } + } + } + + if (files.length === 0) { + console.log(`Output ${outputPath}`); + return; + } + + // Build choices + const choices: Array<{ name: string; value: string }> = []; + for (const f of files) { + const fullPath = `${sessionDir}/${f}`; + const stat = fs.statSync(fullPath); + const size = + stat.size > 1024 ? `${Math.round(stat.size / 1024)}KB` : `${stat.size}B`; + choices.push({ name: `Open ${f} (${size})`, value: `file:${fullPath}` }); + } + choices.push({ name: 'Open session folder', value: `folder:${sessionDir}` }); + choices.push({ name: 'Done', value: 'done' }); + + const action = await select({ + message: 'What next?', + choices, + }); + + if (action === 'done') return; + + const [type, path] = action.split(':'); + const target = action.slice(type!.length + 1); // handle paths with colons + + try { + if (process.platform === 'darwin') { + execSync(`open "${target}"`); + } else if (process.platform === 'linux') { + execSync(`xdg-open "${target}"`); + } else if (process.platform === 'win32') { + execSync(`start "" "${target}"`); + } + } catch { + console.log(`Path: ${target}`); + } +} + // ─── Interactive flow ─────────────────────────────────────────────────────── export async function runInteractiveAgent(options: { @@ -561,8 +624,11 @@ export async function runInteractiveAgent(options: { trimmed === 'exit' || trimmed === 'quit' ) { - process.stderr.write(`\nSession ${session.id} saved.\n`); - process.stderr.write(`Output ${session.outputPath}\n`); + await showSessionEnd( + session.id, + session.outputPath, + getSessionDir(session.id) + ); break; } From 7354d8f3a5536e7549cae0a3421fd58e636519fd Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:43:55 -0400 Subject: [PATCH 17/63] instruct agent to use subagents for scraping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the main agent thread should only orchestrate — all heavy scraping and parsing is delegated to subagents that return just structured JSON records. keeps the main context window clean for planning and consolidation. --- src/commands/agent-interactive.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index ffa3a43..ec58e50 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -123,7 +123,17 @@ Scraping now... \`\`\` ### Phase 3: Extract Data -Scrape each source. As you extract data, report progress: + +**IMPORTANT: Use subagents for all scraping and parsing.** This keeps your context window clean. + +${SUBAGENT_INSTRUCTIONS} + +For each source (or group of related sources), spawn a subagent with a prompt like: +"Scrape [URL] using firecrawl. Extract records with these fields: [field list]. Return ONLY a JSON array of objects — no commentary, no markdown, just the JSON array." + +Launch all extraction subagents in a SINGLE message (parallel). Each subagent handles the heavy work (scraping, reading large pages, parsing) and returns just the structured records. + +After all subagents return, report progress: \`\`\` Extracted 50 from Forbes AI 50 From 2b58bb87b8c84e4930e7cb6def25423022740e44 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:58:32 -0400 Subject: [PATCH 18/63] only show tool calls on completion, not on start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removes the doubled-up · start / ✓ done lines. now each operation prints once when it finishes — clean single line. the agent's own text provides context for what's in progress. --- src/acp/tui.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 39b584d..b74a05c 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -189,22 +189,9 @@ export function startTUI(opts: { onToolCall(call: ToolCallInfo) { const desc = categorizeCall(call, opts.sessionDir); - if (!desc) { - // Background work — show a single "Working..." if we haven't yet - if (!backgroundShown && lastOutputWasText) { - // Don't print anything for background — the agent text provides context - } - return; - } - + if (!desc) return; + // Just register — we print only on completion calls.set(call.id, desc); - - // Deduplicate: don't print if we already showed this exact operation - if (printed.has(desc.dedupeKey)) return; - printed.add(desc.dedupeKey); - - ensureNewSection(); - process.stderr.write(` ${dim('·')} ${desc.label}\n`); }, onToolCallUpdate(call: ToolCallInfo) { @@ -214,7 +201,7 @@ export function startTUI(opts: { if (call.status === 'completed' || call.status === 'errored') { calls.delete(call.id); - // Only print completion if we haven't already for this dedupe key + // Deduplicate: only print once per unique operation if (completed.has(desc.dedupeKey)) return; completed.add(desc.dedupeKey); From 0f59f2f49b559c5cb6dcc0622890a22ac3c50c8b Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:10:09 -0400 Subject: [PATCH 19/63] rewrite TUI with phase sections, usage tracking, and clean layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit complete TUI rewrite: - auto-detected phase sections (Planning → Discovering → Extracting → Output) with ━━━ header lines based on tool call patterns - wire ACP usage_update events for token count + cost tracking - status line shows tokens used + cost + elapsed time - summary footer at session end - remove pipe mode (commit to ACP) - remove old buildCallbacks/describeToolCall (moved to tui.ts) - clean header: "🔥 Firecrawl Agent / Claude Code · CSV · Session xyz" --- src/acp/client.ts | 15 ++ src/acp/tui.ts | 219 +++++++++++++++++------------- src/commands/agent-interactive.ts | 178 ++++-------------------- 3 files changed, 162 insertions(+), 250 deletions(-) diff --git a/src/acp/client.ts b/src/acp/client.ts index cced0fa..b1f5716 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -25,6 +25,11 @@ export interface ACPClientCallbacks { onToolCall?: (call: ToolCallInfo) => void; onToolCallUpdate?: (call: ToolCallInfo) => void; onPlan?: (entries: Array<{ content: string; status: string }>) => void; + onUsage?: (update: { + size: number; + used: number; + cost?: { amount: number; currency: string } | null; + }) => void; onPermissionRequest?: ( title: string, options: Array<{ name: string; optionId: string }> @@ -117,6 +122,16 @@ class FirecrawlClient implements acp.Client { } break; + case 'usage_update': + if (this.callbacks.onUsage) { + this.callbacks.onUsage({ + size: update.size, + used: update.used, + cost: update.cost ?? undefined, + }); + } + break; + default: break; } diff --git a/src/acp/tui.ts b/src/acp/tui.ts index b74a05c..a41f7c7 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -1,40 +1,26 @@ /** - * Firecrawl agent display — clean inline terminal output. + * Firecrawl Agent TUI — phase-aware inline terminal display. * - * Design principles (inspired by Codex CLI): - * - Show firecrawl operations once, update status in place - * - Deduplicate repeated calls to the same URL - * - Group background work into single status lines - * - Visual separation between agent text and tool execution - * - Status summary at turn boundaries + * Renders clear section breaks, tool completions, and a persistent + * status line showing tokens/cost/time. No ANSI cursor tricks — + * everything scrolls naturally. Works in pipes and as an agent harness. */ import type { ToolCallInfo } from './client'; -// ─── Style helpers (TTY-aware) ────────────────────────────────────────────── +// ─── Styles (TTY-aware) ───────────────────────────────────────────────────── -const isTTY = process.stderr.isTTY; -const dim = (s: string) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s); -const green = (s: string) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s); -const red = (s: string) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s); -const bold = (s: string) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s); +const tty = process.stderr.isTTY; +const dim = (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s); +const green = (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s); +const red = (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s); +const cyan = (s: string) => (tty ? `\x1b[36m${s}\x1b[0m` : s); +const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s); +const BAR = '━'; // ─── Tool call categorization ─────────────────────────────────────────────── -type CallKind = - | 'search' - | 'scrape' - | 'map' - | 'crawl' - | 'extract' - | 'write' - | 'background'; - -interface CallDescription { - label: string; - kind: CallKind; - dedupeKey: string; // for deduplication (e.g., URL) -} +type Phase = 'planning' | 'discovering' | 'extracting' | 'output'; function extractUrl(cmd: string, prefix: string): string | null { const quoted = cmd.match( @@ -49,73 +35,80 @@ function extractUrl(cmd: string, prefix: string): string | null { return null; } -function categorizeCall( - call: ToolCallInfo, - sessionDir: string -): CallDescription | null { +interface CallInfo { + label: string; + phase: Phase; + dedupeKey: string; +} + +function categorize(call: ToolCallInfo, sessionDir: string): CallInfo | null { const input = call.rawInput as Record | undefined; - const title = call.title.toLowerCase(); if (input?.command && typeof input.command === 'string') { const cmd = input.command.trim(); if (cmd.startsWith('firecrawl search')) { - const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); - const query = match ? match[1] : 'web'; + const m = cmd.match(/firecrawl search\s+["']([^"']+)["']/); + const q = m ? m[1] : 'web'; return { - label: `Searching "${query}"`, - kind: 'search', - dedupeKey: `search:${query}`, + label: `Searched "${q}"`, + phase: 'discovering', + dedupeKey: `search:${q}`, }; } if (cmd.startsWith('firecrawl scrape')) { const url = extractUrl(cmd, 'firecrawl scrape'); if (!url) return null; return { - label: `Scraping ${url}`, - kind: 'scrape', + label: `Scraped ${url}`, + phase: 'extracting', dedupeKey: `scrape:${url}`, }; } if (cmd.startsWith('firecrawl map')) { const url = extractUrl(cmd, 'firecrawl map'); if (!url) return null; - return { label: `Mapping ${url}`, kind: 'map', dedupeKey: `map:${url}` }; + return { + label: `Mapped ${url}`, + phase: 'extracting', + dedupeKey: `map:${url}`, + }; } if (cmd.startsWith('firecrawl crawl')) { const url = extractUrl(cmd, 'firecrawl crawl'); if (!url) return null; return { - label: `Crawling ${url}`, - kind: 'crawl', + label: `Crawled ${url}`, + phase: 'extracting', dedupeKey: `crawl:${url}`, }; } if (cmd.startsWith('firecrawl agent')) { return { - label: 'Running extraction agent', - kind: 'extract', - dedupeKey: 'extract', + label: 'Ran extraction agent', + phase: 'extracting', + dedupeKey: 'extract-agent', }; } if (cmd.includes(sessionDir)) { return { - label: 'Writing output', - kind: 'write', + label: 'Wrote output', + phase: 'output', dedupeKey: 'write-session', }; } - // All other commands are background return null; } - // File writes to session dir if (input?.path && typeof input.path === 'string') { - if (input.path.startsWith(sessionDir) && title.includes('write')) { + if ( + input.path.startsWith(sessionDir) && + call.title.toLowerCase().includes('write') + ) { const basename = input.path.split('/').pop() || input.path; return { - label: `Writing ${basename}`, - kind: 'write', + label: `Wrote ${basename}`, + phase: 'output', dedupeKey: `write:${basename}`, }; } @@ -125,14 +118,34 @@ function categorizeCall( return null; } +// ─── Section header ───────────────────────────────────────────────────────── + +const SECTION_WIDTH = 54; + +function sectionHeader(name: string): string { + const pad = SECTION_WIDTH - name.length - 5; // "━━━ Name " + bars + return dim(`${BAR.repeat(3)} ${name} ${BAR.repeat(Math.max(pad, 3))}`); +} + +function sectionFooter(): string { + return dim(BAR.repeat(SECTION_WIDTH)); +} + // ─── Public API ───────────────────────────────────────────────────────────── export interface TUIHandle { onText: (text: string) => void; onToolCall: (call: ToolCallInfo) => void; onToolCallUpdate: (call: ToolCallInfo) => void; - addCredits: (n: number) => void; + onUsage: (update: { + size: number; + used: number; + cost?: { amount: number; currency: string } | null; + }) => void; + + section: (name: string) => void; printStatus: () => void; + printSummary: () => void; pause: () => void; resume: () => void; cleanup: () => void; @@ -144,17 +157,15 @@ export function startTUI(opts: { format: string; sessionDir: string; }): TUIHandle { - // Track calls by ID → description - const calls = new Map(); - // Track which dedupe keys we've already printed (to avoid duplicate lines) - const printed = new Set(); - // Track completed dedupe keys + const calls = new Map(); const completed = new Set(); + let currentPhase: Phase | null = null; - let credits = 0; + // Metrics + let tokensUsed = 0; + let tokensTotal = 0; + let cost: { amount: number; currency: string } | null = null; const startedAt = Date.now(); - let lastOutputWasText = false; - let backgroundShown = false; function elapsed(): string { const secs = Math.round((Date.now() - startedAt) / 1000); @@ -163,76 +174,94 @@ export function startTUI(opts: { return m > 0 ? `${m}m ${s}s` : `${s}s`; } + function statusParts(): string[] { + const parts: string[] = []; + if (cost) { + parts.push(`$${cost.amount.toFixed(2)}`); + } + if (tokensUsed > 0) { + const k = Math.round(tokensUsed / 1000); + parts.push(`${k}k tokens`); + } + parts.push(elapsed()); + return parts; + } + function statusLine(): string { - const fmt = opts.format.toUpperCase(); - return dim( - `── ${opts.sessionId} · ${opts.agentName} · ${credits} credits · ${elapsed()} · ${fmt}` - ); + return dim(statusParts().join(' · ')); } - function ensureNewSection() { - if (lastOutputWasText) { - lastOutputWasText = false; - } + function ensurePhase(phase: Phase) { + if (phase === currentPhase) return; + const names: Record = { + planning: 'Planning', + discovering: 'Discovering', + extracting: 'Extracting', + output: 'Output', + }; + currentPhase = phase; + process.stderr.write(`\n${sectionHeader(names[phase])}\n\n`); } return { onText(text: string) { - // If we were showing tool calls and now getting text, add spacing - if (!lastOutputWasText && printed.size > 0) { - process.stdout.write('\n'); - } process.stdout.write(text); - lastOutputWasText = true; - backgroundShown = false; }, onToolCall(call: ToolCallInfo) { - const desc = categorizeCall(call, opts.sessionDir); - if (!desc) return; - // Just register — we print only on completion - calls.set(call.id, desc); + const info = categorize(call, opts.sessionDir); + if (!info) return; + calls.set(call.id, info); }, onToolCallUpdate(call: ToolCallInfo) { - const desc = calls.get(call.id); - if (!desc) return; + const info = calls.get(call.id); + if (!info) return; if (call.status === 'completed' || call.status === 'errored') { calls.delete(call.id); + if (completed.has(info.dedupeKey)) return; + completed.add(info.dedupeKey); - // Deduplicate: only print once per unique operation - if (completed.has(desc.dedupeKey)) return; - completed.add(desc.dedupeKey); - + ensurePhase(info.phase); const icon = call.status === 'completed' ? green('✓') : red('✗'); - process.stderr.write(` ${icon} ${desc.label}\n`); + process.stderr.write(` ${icon} ${info.label}\n`); + } + }, + + onUsage(update) { + tokensUsed = update.used; + tokensTotal = update.size; + if (update.cost) { + cost = { amount: update.cost.amount, currency: update.cost.currency }; } }, - addCredits(n: number) { - credits += n; + section(name: string) { + currentPhase = null; // reset so auto-phase doesn't conflict + process.stderr.write(`\n${sectionHeader(name)}\n\n`); }, printStatus() { - process.stderr.write(`\n${statusLine()}\n\n`); + process.stderr.write(`${statusLine()}\n`); + }, + + printSummary() { + process.stderr.write(`\n${sectionFooter()}\n`); + process.stderr.write(`${statusLine()}\n`); }, pause() { process.stderr.write(`\n${statusLine()}\n`); - lastOutputWasText = false; }, resume() { - // Reset dedup for next turn — same URLs may be re-scraped intentionally - printed.clear(); completed.clear(); - backgroundShown = false; - lastOutputWasText = false; + currentPhase = null; }, cleanup() { - process.stderr.write(`\n${statusLine()}\n`); + // noop — summary is explicit }, }; } diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index ec58e50..cb50fe7 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -8,7 +8,7 @@ */ import { type ACPAgent, detectAgents } from '../acp/registry'; -import { connectToAgent, type ToolCallInfo } from '../acp/client'; +import { connectToAgent } from '../acp/client'; import { startTUI } from '../acp/tui'; import { createSession, @@ -19,9 +19,9 @@ import { import { FIRECRAWL_TOOLS_BLOCK, SUBAGENT_INSTRUCTIONS, - askPermissionMode, } from './experimental/shared'; -import { type Backend, BACKENDS, launchAgent } from './experimental/backends'; + +const bold = (s: string) => (process.stderr.isTTY ? `\x1b[1m${s}\x1b[0m` : s); // ─── Suggestions ──────────────────────────────────────────────────────────── @@ -176,118 +176,6 @@ ${outputInstructions[opts.format] || outputInstructions.json} - Keep the user informed at every step — they should never wonder what you're doing.`; } -// ─── Tool call display ────────────────────────────────────────────────────── - -/** - * Parse a tool call and return a user-facing label, or null to hide it. - * Only firecrawl operations and session output writes are shown. - */ -/** Extract a URL from a firecrawl command, ignoring flags, pipes, and redirects. */ -function extractUrl(cmd: string, prefix: string): string | null { - // Try quoted URL first - const quoted = cmd.match( - new RegExp(`${prefix}\\s+["'](https?://[^"']+)["']`) - ); - if (quoted) return quoted[1]; - // Try unquoted URL - const parts = cmd.replace(new RegExp(`^${prefix}\\s*`), '').split(/\s+/); - for (const part of parts) { - const clean = part.replace(/^["']|["']$/g, ''); - if (clean.startsWith('http')) return clean; - } - return null; -} - -function describeToolCall( - call: ToolCallInfo, - sessionDir: string -): string | null { - const input = call.rawInput as Record | undefined; - - if (input?.command && typeof input.command === 'string') { - const cmd = input.command.trim(); - - if (cmd.startsWith('firecrawl search')) { - // Extract just the quoted query, stripping flags and pipes - const match = cmd.match(/firecrawl search\s+["']([^"']+)["']/); - if (match) return `Searching "${match[1]}"`; - const arg = cmd - .replace(/^firecrawl search\s*/, '') - .split(/\s+/)[0] - .replace(/^["']|["']$/g, ''); - return `Searching "${arg}"`; - } - if (cmd.startsWith('firecrawl scrape')) { - const url = extractUrl(cmd, 'firecrawl scrape'); - if (url) return `Scraping ${url}`; - return null; - } - if (cmd.startsWith('firecrawl map')) { - const url = extractUrl(cmd, 'firecrawl map'); - if (url) return `Mapping ${url}`; - return null; - } - if (cmd.startsWith('firecrawl crawl')) { - const url = extractUrl(cmd, 'firecrawl crawl'); - if (url) return `Crawling ${url}`; - return null; - } - if (cmd.startsWith('firecrawl agent')) { - return 'Running Firecrawl extraction agent'; - } - - // Hide everything else (grep, python, cat, temp files, help, etc.) - return null; - } - - // File writes to the session directory are shown - if (input?.path && typeof input.path === 'string') { - if ( - input.path.startsWith(sessionDir) && - call.title.toLowerCase().includes('write') - ) { - const basename = input.path.split('/').pop() || input.path; - return `Writing ${basename}`; - } - return null; - } - - return null; -} - -// ─── Tool call display ────────────────────────────────────────────────────── - -function buildCallbacks(sessionDir: string): { - onText: (text: string) => void; - onToolCall: (call: ToolCallInfo) => void; - onToolCallUpdate: (call: ToolCallInfo) => void; - cleanup: () => void; -} { - const pending = new Map(); - - return { - onText: (text: string) => { - process.stdout.write(text); - }, - onToolCall: (call: ToolCallInfo) => { - const label = describeToolCall(call, sessionDir); - if (!label) return; - pending.set(call.id, label); - process.stderr.write(` · ${label}\n`); - }, - onToolCallUpdate: (call: ToolCallInfo) => { - if (!pending.has(call.id)) return; - const label = pending.get(call.id)!; - if (call.status === 'completed' || call.status === 'errored') { - pending.delete(call.id); - const icon = call.status === 'completed' ? '✓' : '✗'; - process.stderr.write(` ${icon} ${label}\n`); - } - }, - cleanup: () => {}, - }; -} - // ─── Session end ──────────────────────────────────────────────────────────── async function showSessionEnd( @@ -390,16 +278,29 @@ export async function runInteractiveAgent(options: { const userMessage = `Continue from previous session. Original request: "${session.prompt}". Schema fields: ${session.schema.join(', ')}. Output already at: ${session.outputPath}. New instruction: ${refinement}`; - console.log(`\nResuming session via Agent Client Protocol...\n`); + console.log(`\n🔥 ${bold('Firecrawl Agent')} — Resuming session\n`); + + const resumeTui = startTUI({ + sessionId: session.id, + agentName: session.provider, + format: session.format, + sessionDir: getSessionDir(session.id), + }); const agent = await connectToAgent({ bin: session.provider, systemPrompt, - callbacks: buildCallbacks(getSessionDir(session.id)), + callbacks: { + onText: (text) => resumeTui.onText(text), + onToolCall: (call) => resumeTui.onToolCall(call), + onToolCallUpdate: (call) => resumeTui.onToolCallUpdate(call), + onUsage: (update) => resumeTui.onUsage(update), + }, }); try { await agent.prompt(userMessage); + resumeTui.printSummary(); } finally { agent.close(); } @@ -471,33 +372,6 @@ export async function runInteractiveAgent(options: { selectedAgent = agents.find((a) => a.name === chosen)!; } - // ── Select mode ───────────────────────────────────────────────────────── - // Check if this agent also has a direct CLI (pipe mode) - const pipeBackends: Record = { - claude: 'claude', - codex: 'codex', - opencode: 'opencode', - }; - const hasPipeMode = selectedAgent.name in pipeBackends; - - let useACP = true; - if (hasPipeMode) { - const mode = await select({ - message: 'How should the agent run?', - choices: [ - { - name: 'ACP (structured — shows progress, credits, session tracking)', - value: 'acp', - }, - { - name: `Pipe into ${selectedAgent.displayName} (full terminal, interactive)`, - value: 'pipe', - }, - ], - }); - useACP = mode === 'acp'; - } - // ── Gather prompt ─────────────────────────────────────────────────────── const promptChoice = await select({ message: 'What data do you want to gather?', @@ -557,18 +431,10 @@ export async function runInteractiveAgent(options: { if (urls.trim()) parts.push(`Start from these URLs: ${urls}`); const userMessage = parts.join('. ') + '.'; - // ── Pipe mode — launch directly into the agent CLI ─────────────────── - if (!useACP && hasPipeMode) { - const backend = pipeBackends[selectedAgent.name]; - const skipPermissions = options.yes || (await askPermissionMode(backend)); - console.log(`\nLaunching ${selectedAgent.displayName}...\n`); - launchAgent(backend, systemPrompt, userMessage, skipPermissions); - return; - } - // ── Connect via ACP ─────────────────────────────────────────────────── + console.log(`\n🔥 ${bold('Firecrawl Agent')}`); console.log( - `\n🔥 Loading ${selectedAgent.displayName} via Agent Client Protocol...\n` + ` ${selectedAgent.displayName} · ${format.toUpperCase()} · Session ${session.id}\n` ); // Start TUI @@ -602,6 +468,7 @@ export async function runInteractiveAgent(options: { onText: (text) => tui.onText(text), onToolCall: (call) => tui.onToolCall(call), onToolCallUpdate: (call) => tui.onToolCallUpdate(call), + onUsage: (update) => tui.onUsage(update), }, }); @@ -616,7 +483,7 @@ export async function runInteractiveAgent(options: { // If the agent stopped for a reason other than end_turn, break if (result.stopReason !== 'end_turn') { - process.stderr.write(`\nStopped (${result.stopReason}).\n`); + tui.printSummary(); break; } @@ -634,6 +501,7 @@ export async function runInteractiveAgent(options: { trimmed === 'exit' || trimmed === 'quit' ) { + tui.printSummary(); await showSessionEnd( session.id, session.outputPath, From 026ea95180232cdf44f1284427385f0f6d81b41b Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:16:46 -0400 Subject: [PATCH 20/63] remove cost display, only show tokens + credits + time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP agents handle their own billing — don't show dollar amounts. status line now shows: "25k tokens · 12 credits · 47s" also stop printing status before every → prompt — only at session end. --- src/acp/tui.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index a41f7c7..a7371a8 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -142,6 +142,7 @@ export interface TUIHandle { used: number; cost?: { amount: number; currency: string } | null; }) => void; + addCredits: (n: number) => void; section: (name: string) => void; printStatus: () => void; @@ -164,7 +165,7 @@ export function startTUI(opts: { // Metrics let tokensUsed = 0; let tokensTotal = 0; - let cost: { amount: number; currency: string } | null = null; + let firecrawlCredits = 0; const startedAt = Date.now(); function elapsed(): string { @@ -174,21 +175,17 @@ export function startTUI(opts: { return m > 0 ? `${m}m ${s}s` : `${s}s`; } - function statusParts(): string[] { + function statusLine(): string { const parts: string[] = []; - if (cost) { - parts.push(`$${cost.amount.toFixed(2)}`); - } if (tokensUsed > 0) { const k = Math.round(tokensUsed / 1000); parts.push(`${k}k tokens`); } + if (firecrawlCredits > 0) { + parts.push(`${firecrawlCredits} credits`); + } parts.push(elapsed()); - return parts; - } - - function statusLine(): string { - return dim(statusParts().join(' · ')); + return dim(parts.join(' · ')); } function ensurePhase(phase: Phase) { @@ -232,9 +229,10 @@ export function startTUI(opts: { onUsage(update) { tokensUsed = update.used; tokensTotal = update.size; - if (update.cost) { - cost = { amount: update.cost.amount, currency: update.cost.currency }; - } + }, + + addCredits(n: number) { + firecrawlCredits += n; }, section(name: string) { @@ -252,7 +250,8 @@ export function startTUI(opts: { }, pause() { - process.stderr.write(`\n${statusLine()}\n`); + // Just add spacing before user prompt — no status line mid-conversation + process.stderr.write('\n'); }, resume() { From 83bf30239ab8196e6feeeb0ece84f1950f777d31 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:17:29 -0400 Subject: [PATCH 21/63] show working indicator during background operations when the agent is doing non-firecrawl work (reading files, running scripts, spawning subagents), show a dim "..." on the current line. clears when agent text arrives or a firecrawl operation completes. prevents the dead cursor stare. --- src/acp/tui.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index a7371a8..8d01e62 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -200,26 +200,50 @@ export function startTUI(opts: { process.stderr.write(`\n${sectionHeader(names[phase])}\n\n`); } + // Track if we're showing a working indicator + let workingShown = false; + + function showWorking() { + if (workingShown || !tty) return; + workingShown = true; + process.stderr.write(` ${dim('...')}\r`); + } + + function clearWorking() { + if (!workingShown) return; + workingShown = false; + process.stderr.write(`\r\x1b[2K`); // clear the line + } + return { onText(text: string) { + clearWorking(); process.stdout.write(text); }, onToolCall(call: ToolCallInfo) { const info = categorize(call, opts.sessionDir); - if (!info) return; + if (!info) { + // Background work — show subtle indicator + showWorking(); + return; + } calls.set(call.id, info); }, onToolCallUpdate(call: ToolCallInfo) { const info = calls.get(call.id); - if (!info) return; + if (!info) { + // Background work completed — keep working indicator if others pending + return; + } if (call.status === 'completed' || call.status === 'errored') { calls.delete(call.id); if (completed.has(info.dedupeKey)) return; completed.add(info.dedupeKey); + clearWorking(); ensurePhase(info.phase); const icon = call.status === 'completed' ? green('✓') : red('✗'); process.stderr.write(` ${icon} ${info.label}\n`); From 7d38fc5ea048d7f051ab8eefd1a62dcaceef857c Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:19:17 -0400 Subject: [PATCH 22/63] restrict agent to search + scrape only, no browser replace FIRECRAWL_TOOLS_BLOCK with explicit command list that excludes browser/interact tools. agent should use scrape with --wait-for for JS-rendered pages instead of browser sessions. --- src/commands/agent-interactive.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index cb50fe7..eff0199 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -87,7 +87,15 @@ Each record object must have identical keys. Tell the user the file path and rec You are running inside a CLI. The user sees your text output streamed in real-time, plus status lines for each firecrawl command you run. Structure your output for readability in a terminal. -${FIRECRAWL_TOOLS_BLOCK} +Use ONLY these firecrawl commands (already installed and authenticated): +- \`firecrawl search ""\` — Search the web +- \`firecrawl scrape \` — Scrape a page as markdown +- \`firecrawl map \` — Discover all URLs on a site +- \`firecrawl crawl \` — Crawl an entire site + +**Do NOT use \`firecrawl browser\`, \`firecrawl interact\`, or any browser-based tools.** Stick to search + scrape. If a page requires JavaScript rendering, use \`firecrawl scrape --wait-for 3000\`. + +Run \`firecrawl --help\` first to see all available commands. ## How You Work From 22d461852721fa8e1f2de6c9417dcdd3babcec3f Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:22:05 -0400 Subject: [PATCH 23/63] show output path at prompt + follow-up suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - display output file path (dimmed) above each → prompt - add "follow-up suggestions" instruction to system prompt: agent proposes 2-3 specific next steps after completing output, like perplexity's suggested questions --- src/commands/agent-interactive.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index eff0199..332a7c6 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -22,6 +22,7 @@ import { } from './experimental/shared'; const bold = (s: string) => (process.stderr.isTTY ? `\x1b[1m${s}\x1b[0m` : s); +const dim = (s: string) => (process.stderr.isTTY ? `\x1b[2m${s}\x1b[0m` : s); // ─── Suggestions ──────────────────────────────────────────────────────────── @@ -181,7 +182,20 @@ ${outputInstructions[opts.format] || outputInstructions.json} - Use bullet points and code-style backticks for field names. - Do NOT use markdown tables — they render poorly in terminals. Use bullet points or plain text. - Report numbers: "Found 4 sources", "Extracted 50 records", "Deduplicated 181 → 127". -- Keep the user informed at every step — they should never wonder what you're doing.`; +- Keep the user informed at every step — they should never wonder what you're doing. + +## Follow-Up Suggestions + +After completing the output file, always end your message with 2-3 suggested follow-up actions the user might want. Format them as a numbered list, like: + +\`\`\` +Next steps: +1. Add funding amounts and team sizes +2. Filter to only companies founded after 2020 +3. Export as JSON with nested category tags +\`\`\` + +These should be specific to the data just gathered — not generic. Think about what would make the dataset more useful.`; } // ─── Session end ──────────────────────────────────────────────────────────── @@ -495,6 +509,9 @@ export async function runInteractiveAgent(options: { break; } + // Show output path reminder + process.stderr.write(dim(`Output: ${session.outputPath}\n`)); + // Ask user for follow-up const followUp = await input({ message: '→', From 9a769c28f945d078bb4c6ceb9cd13d9628d7f671 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:24:26 -0400 Subject: [PATCH 24/63] give agent awareness of session directory for incremental writes system prompt now tells the agent its session directory path and output file path. agent can write intermediate files, build data incrementally, and preserve progress if interrupted. --- src/commands/agent-interactive.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 332a7c6..d2b77d4 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -88,16 +88,25 @@ Each record object must have identical keys. Tell the user the file path and rec You are running inside a CLI. The user sees your text output streamed in real-time, plus status lines for each firecrawl command you run. Structure your output for readability in a terminal. +## Session Directory + +Your working directory for this session is: \`${opts.sessionDir}\` + +All output files go here. You can also read/write intermediate files here (e.g., partial results, scripts). The user's output file is: \`${opts.sessionDir}/output.${opts.format === 'csv' ? 'csv' : opts.format === 'json' ? 'json' : 'md'}\` + +If you need to build the dataset incrementally, write partial results to the session directory as you go. This way if the session is interrupted, progress is preserved. + +## Tools + Use ONLY these firecrawl commands (already installed and authenticated): - \`firecrawl search ""\` — Search the web - \`firecrawl scrape \` — Scrape a page as markdown +- \`firecrawl scrape --format json\` — Scrape as structured JSON - \`firecrawl map \` — Discover all URLs on a site - \`firecrawl crawl \` — Crawl an entire site **Do NOT use \`firecrawl browser\`, \`firecrawl interact\`, or any browser-based tools.** Stick to search + scrape. If a page requires JavaScript rendering, use \`firecrawl scrape --wait-for 3000\`. -Run \`firecrawl --help\` first to see all available commands. - ## How You Work You work in clear phases. **STOP after each phase** and wait for user input before continuing. Do not rush ahead. The user is watching and may want to adjust. From a398dde45fea5b91792bd2df9d051f726c4f892a Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:25:36 -0400 Subject: [PATCH 25/63] default to claude code when available, skip agent selection --- src/commands/agent-interactive.ts | 75 ++++++++++++++++--------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index d2b77d4..5fec8cd 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -364,43 +364,48 @@ export async function runInteractiveAgent(options: { process.exit(1); } selectedAgent = match; - } else if (available.length === 1) { - selectedAgent = available[0]; - console.log(`\nUsing ${selectedAgent.displayName} (only agent detected)\n`); } else { - // Available agents first, then unavailable grouped at the bottom - const installedChoices = agents - .filter((a) => a.available) - .map((a) => ({ - name: `${a.displayName}`, - value: a.name, - disabled: false as const, - })); - const notInstalled = agents.filter((a) => !a.available); - const agentChoices = [ - ...installedChoices, - ...(notInstalled.length > 0 - ? [ - { - name: '─── Not installed ───', - value: '_sep', - disabled: 'separator' as const, - }, - ...notInstalled.map((a) => ({ - name: `${a.displayName}`, - value: a.name, - disabled: 'not installed' as const, - })), - ] - : []), - ]; - - const chosen = await select({ - message: 'Which ACP agent?', - choices: agentChoices, - }); + // Default to Claude Code if available + const claude = available.find((a) => a.name === 'claude'); + if (claude) { + selectedAgent = claude; + } else if (available.length === 1) { + selectedAgent = available[0]; + } else { + // Available agents first, then unavailable grouped at the bottom + const installedChoices = agents + .filter((a) => a.available) + .map((a) => ({ + name: `${a.displayName}`, + value: a.name, + disabled: false as const, + })); + const notInstalled = agents.filter((a) => !a.available); + const agentChoices = [ + ...installedChoices, + ...(notInstalled.length > 0 + ? [ + { + name: '─── Not installed ───', + value: '_sep', + disabled: 'separator' as const, + }, + ...notInstalled.map((a) => ({ + name: `${a.displayName}`, + value: a.name, + disabled: 'not installed' as const, + })), + ] + : []), + ]; + + const chosen = await select({ + message: 'Which ACP agent?', + choices: agentChoices, + }); - selectedAgent = agents.find((a) => a.name === chosen)!; + selectedAgent = agents.find((a) => a.name === chosen)!; + } } // ── Gather prompt ─────────────────────────────────────────────────────── From 28d797aa8d9a4fdcf8a0816a46e5b46f86db1655 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:28:46 -0400 Subject: [PATCH 26/63] default to free text input, show examples on empty enter --- src/commands/agent-interactive.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 5fec8cd..dba5d4d 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -409,22 +409,18 @@ export async function runInteractiveAgent(options: { } // ── Gather prompt ─────────────────────────────────────────────────────── - const promptChoice = await select({ + let prompt = await input({ message: 'What data do you want to gather?', - choices: [ - ...SUGGESTIONS.map((s) => ({ name: s.name, value: s.value })), - { name: 'Describe your own...', value: '__custom__' }, - ], + default: '', }); - let prompt: string; - if (promptChoice === '__custom__') { - prompt = await input({ - message: 'Describe the data you want to collect:', - validate: (v: string) => (v.trim() ? true : 'Prompt is required'), + // If empty, show suggestions to pick from + if (!prompt.trim()) { + const picked = await select({ + message: 'Pick an example:', + choices: SUGGESTIONS.map((s) => ({ name: s.name, value: s.value })), }); - } else { - prompt = promptChoice; + prompt = picked; } // ── Seed URLs ─────────────────────────────────────────────────────────── From 9cc778d617fcefb9a6190b562249f1624c561c67 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:39:24 -0400 Subject: [PATCH 27/63] frame follow-up suggestions as questions, not actions --- src/commands/agent-interactive.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index dba5d4d..4bd0b7a 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -195,13 +195,13 @@ ${outputInstructions[opts.format] || outputInstructions.json} ## Follow-Up Suggestions -After completing the output file, always end your message with 2-3 suggested follow-up actions the user might want. Format them as a numbered list, like: +After completing the output file, always end your message with 2-3 suggested follow-up questions the user can ask. Frame them as questions, not actions. Like: \`\`\` -Next steps: -1. Add funding amounts and team sizes -2. Filter to only companies founded after 2020 -3. Export as JSON with nested category tags +Want to go deeper? +1. Want me to add star counts and primary language for each repo? +2. Should I expand this to the top 25 trending repos? +3. Want a comparison across languages (Python, TypeScript, Rust)? \`\`\` These should be specific to the data just gathered — not generic. Think about what would make the dataset more useful.`; From bd9053860ee23f5f08a83e177c79c5ab72bc3491 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:40:24 -0400 Subject: [PATCH 28/63] rename extracting phase to gathering data --- src/acp/tui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 8d01e62..f1b0b15 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -193,7 +193,7 @@ export function startTUI(opts: { const names: Record = { planning: 'Planning', discovering: 'Discovering', - extracting: 'Extracting', + extracting: 'Gathering Data', output: 'Output', }; currentPhase = phase; From c93a30116836a34bc73b5676a5cf3ba23417aeee Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:41:56 -0400 Subject: [PATCH 29/63] revert tsconfig to original module/resolution settings the module:"preserve" and moduleResolution:"bundler" changes were for ink which was removed. reverted to commonjs + node. --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index ab09f5a..b4a3f82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "module": "preserve", + "module": "commonjs", "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -12,7 +12,7 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "moduleResolution": "bundler", + "moduleResolution": "node", "allowSyntheticDefaultImports": true, "types": ["node"] }, From 26a8b67ce869566a3353a12125fc7cb7e7a17297 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:45:56 -0400 Subject: [PATCH 30/63] Update agent-interactive.ts --- src/commands/agent-interactive.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 4bd0b7a..c4dd40d 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -109,7 +109,9 @@ Use ONLY these firecrawl commands (already installed and authenticated): ## How You Work -You work in clear phases. **STOP after each phase** and wait for user input before continuing. Do not rush ahead. The user is watching and may want to adjust. +**Match your effort to the request.** If the user asks for data from one site, scrape that one site and be done. Don't over-engineer simple requests into multi-source research projects. Only expand to multiple sources when the request genuinely needs it (e.g., "top 50 AI startups" requires multiple lists). + +You work in clear phases. **STOP after Phase 1 (Plan)** and wait for user input before continuing. Do not rush ahead. ### Phase 1: Plan Propose a schema (list fields as bullet points, not a table — tables render poorly in terminals) and a brief plan of what sources you'll check. Then STOP and wait. From 5bdbe7680bac038855bbd661cf7aec2ad3d3ccd3 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:46:47 -0400 Subject: [PATCH 31/63] skip planning phase for simple single-source requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simple requests (pricing from one site) go straight to scrape + output without asking "shall I proceed?" — just do it. only stop for confirmation on multi-source research tasks. --- src/commands/agent-interactive.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index c4dd40d..a2d236c 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -109,9 +109,12 @@ Use ONLY these firecrawl commands (already installed and authenticated): ## How You Work -**Match your effort to the request.** If the user asks for data from one site, scrape that one site and be done. Don't over-engineer simple requests into multi-source research projects. Only expand to multiple sources when the request genuinely needs it (e.g., "top 50 AI startups" requires multiple lists). +**Match your effort to the request:** +- **Simple request** (one site, specific data): Skip the plan. Just scrape it, extract the data, write the output. Done in one turn. +- **Medium request** (a few sources): Propose a quick plan, then execute after confirmation. +- **Large request** (many sources, comprehensive): Full plan with schema confirmation, then multi-source extraction. -You work in clear phases. **STOP after Phase 1 (Plan)** and wait for user input before continuing. Do not rush ahead. +For simple requests, do NOT ask "shall I proceed?" — just do it. Only stop for confirmation on medium/large requests where the plan matters. ### Phase 1: Plan Propose a schema (list fields as bullet points, not a table — tables render poorly in terminals) and a brief plan of what sources you'll check. Then STOP and wait. From 2948cca7c8b56703df7a71cf4de408539e54ef2a Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:56:51 -0400 Subject: [PATCH 32/63] trim agent registry to claude code and codex only --- src/acp/registry.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/acp/registry.ts b/src/acp/registry.ts index 8c81007..802bc7e 100644 --- a/src/acp/registry.ts +++ b/src/acp/registry.ts @@ -14,11 +14,6 @@ export interface ACPAgent { const KNOWN_AGENTS: Omit[] = [ { name: 'claude', bin: 'claude-agent-acp', displayName: 'Claude Code' }, { name: 'codex', bin: 'codex-acp', displayName: 'Codex' }, - { name: 'gemini', bin: 'gemini', displayName: 'Gemini CLI' }, - { name: 'opencode', bin: 'opencode', displayName: 'OpenCode' }, - { name: 'goose', bin: 'goose', displayName: 'Goose' }, - { name: 'kimi', bin: 'kimi', displayName: 'Kimi CLI' }, - { name: 'augment', bin: 'augment', displayName: 'Augment Code' }, ]; function isBinAvailable(bin: string): boolean { From a7407569425932bd94576c254fdb9982d5fad954 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:58:21 -0400 Subject: [PATCH 33/63] remove dead provider detection from utils/acp.ts ACPProvider, detectProviders, and PROVIDERS are replaced by src/acp/registry.ts. utils/acp.ts now only handles sessions. --- src/utils/acp.ts | 39 ++------------------------------------- 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/src/utils/acp.ts b/src/utils/acp.ts index 6d98287..6b0e9a0 100644 --- a/src/utils/acp.ts +++ b/src/utils/acp.ts @@ -1,24 +1,15 @@ /** - * ACP (Agent Client Protocol) provider detection and session management. + * Session management for Firecrawl Agent. * - * Detects locally-installed agent providers (Claude Code, Codex, OpenCode) - * and manages persistent sessions in ~/.firecrawl/sessions/. + * Manages persistent sessions in ~/.firecrawl/sessions/. */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; // ─── Types ────────────────────────────────────────────────────────────────── -export interface ACPProvider { - name: string; - bin: string; - displayName: string; - available: boolean; -} - export interface Session { id: string; provider: string; @@ -31,32 +22,6 @@ export interface Session { iterations: number; } -// ─── Provider registry ────────────────────────────────────────────────────── - -const PROVIDERS: Omit[] = [ - { name: 'claude', bin: 'claude', displayName: 'Claude Code' }, - { name: 'codex', bin: 'codex', displayName: 'Codex' }, - { name: 'opencode', bin: 'opencode', displayName: 'OpenCode' }, -]; - -// ─── Provider detection ───────────────────────────────────────────────────── - -function isBinAvailable(bin: string): boolean { - try { - execSync(`which ${bin}`, { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -export function detectProviders(): ACPProvider[] { - return PROVIDERS.map((p) => ({ - ...p, - available: isBinAvailable(p.bin), - })); -} - // ─── Sessions directory ───────────────────────────────────────────────────── function getFirecrawlDir(): string { From 7392a49e863dc43b9b098f737340e2aeaf1f5859 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:03:08 -0400 Subject: [PATCH 34/63] always show agent selection as first step --- src/commands/agent-interactive.ts | 69 +++++++++++++------------------ 1 file changed, 29 insertions(+), 40 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index a2d236c..49972a1 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -370,47 +370,36 @@ export async function runInteractiveAgent(options: { } selectedAgent = match; } else { - // Default to Claude Code if available - const claude = available.find((a) => a.name === 'claude'); - if (claude) { - selectedAgent = claude; - } else if (available.length === 1) { - selectedAgent = available[0]; - } else { - // Available agents first, then unavailable grouped at the bottom - const installedChoices = agents - .filter((a) => a.available) - .map((a) => ({ - name: `${a.displayName}`, - value: a.name, - disabled: false as const, - })); - const notInstalled = agents.filter((a) => !a.available); - const agentChoices = [ - ...installedChoices, - ...(notInstalled.length > 0 - ? [ - { - name: '─── Not installed ───', - value: '_sep', - disabled: 'separator' as const, - }, - ...notInstalled.map((a) => ({ - name: `${a.displayName}`, - value: a.name, - disabled: 'not installed' as const, - })), - ] - : []), - ]; - - const chosen = await select({ - message: 'Which ACP agent?', - choices: agentChoices, - }); + const installedChoices = available.map((a) => ({ + name: a.displayName, + value: a.name, + disabled: false as const, + })); + const notInstalled = agents.filter((a) => !a.available); + const agentChoices = [ + ...installedChoices, + ...(notInstalled.length > 0 + ? [ + { + name: '─── Not installed ───', + value: '_sep', + disabled: 'separator' as const, + }, + ...notInstalled.map((a) => ({ + name: a.displayName, + value: a.name, + disabled: 'not installed' as const, + })), + ] + : []), + ]; + + const chosen = await select({ + message: 'Agent', + choices: agentChoices, + }); - selectedAgent = agents.find((a) => a.name === chosen)!; - } + selectedAgent = agents.find((a) => a.name === chosen)!; } // ── Gather prompt ─────────────────────────────────────────────────────── From 4399fb72f3a1d1b5a692011ad060a6b0472483f3 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:12:06 -0400 Subject: [PATCH 35/63] fix line overwrite by ensuring newline before stderr output track whether last stdout text ended with \n. if not, write a newline before any tool call results, phase headers, or status lines to prevent them from overwriting partial agent text. --- src/acp/tui.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index f1b0b15..eb8fb2a 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -200,11 +200,20 @@ export function startTUI(opts: { process.stderr.write(`\n${sectionHeader(names[phase])}\n\n`); } - // Track if we're showing a working indicator + // Track cursor state let workingShown = false; + let lastCharWasNewline = true; + + function ensureNewline() { + if (!lastCharWasNewline) { + process.stdout.write('\n'); + lastCharWasNewline = true; + } + } function showWorking() { if (workingShown || !tty) return; + ensureNewline(); workingShown = true; process.stderr.write(` ${dim('...')}\r`); } @@ -212,13 +221,16 @@ export function startTUI(opts: { function clearWorking() { if (!workingShown) return; workingShown = false; - process.stderr.write(`\r\x1b[2K`); // clear the line + process.stderr.write(`\r\x1b[2K`); } return { onText(text: string) { clearWorking(); process.stdout.write(text); + if (text.length > 0) { + lastCharWasNewline = text[text.length - 1] === '\n'; + } }, onToolCall(call: ToolCallInfo) { @@ -244,6 +256,7 @@ export function startTUI(opts: { completed.add(info.dedupeKey); clearWorking(); + ensureNewline(); ensurePhase(info.phase); const icon = call.status === 'completed' ? green('✓') : red('✗'); process.stderr.write(` ${icon} ${info.label}\n`); @@ -260,15 +273,18 @@ export function startTUI(opts: { }, section(name: string) { - currentPhase = null; // reset so auto-phase doesn't conflict + ensureNewline(); + currentPhase = null; process.stderr.write(`\n${sectionHeader(name)}\n\n`); }, printStatus() { + ensureNewline(); process.stderr.write(`${statusLine()}\n`); }, printSummary() { + ensureNewline(); process.stderr.write(`\n${sectionFooter()}\n`); process.stderr.write(`${statusLine()}\n`); }, From 5a39cd8d0244c8de28bee1112d9659cc4fac8573 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:14:33 -0400 Subject: [PATCH 36/63] add animated spinner while agent is working in background --- src/acp/tui.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index eb8fb2a..7e09291 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -202,7 +202,10 @@ export function startTUI(opts: { // Track cursor state let workingShown = false; + let workingInterval: ReturnType | null = null; + let spinFrame = 0; let lastCharWasNewline = true; + const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; function ensureNewline() { if (!lastCharWasNewline) { @@ -215,12 +218,19 @@ export function startTUI(opts: { if (workingShown || !tty) return; ensureNewline(); workingShown = true; - process.stderr.write(` ${dim('...')}\r`); + workingInterval = setInterval(() => { + spinFrame = (spinFrame + 1) % SPIN.length; + process.stderr.write(`\r ${dim(SPIN[spinFrame])}`); + }, 80); } function clearWorking() { if (!workingShown) return; workingShown = false; + if (workingInterval) { + clearInterval(workingInterval); + workingInterval = null; + } process.stderr.write(`\r\x1b[2K`); } @@ -290,17 +300,19 @@ export function startTUI(opts: { }, pause() { - // Just add spacing before user prompt — no status line mid-conversation + clearWorking(); + ensureNewline(); process.stderr.write('\n'); }, resume() { + clearWorking(); completed.clear(); currentPhase = null; }, cleanup() { - // noop — summary is explicit + clearWorking(); }, }; } From 2068011b1d5e0cbb86ddea93f24561e698df173a Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:26:30 -0400 Subject: [PATCH 37/63] stop agent from narrating internal steps and reading workspace configs --- src/commands/agent-interactive.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 49972a1..93c88ea 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -192,11 +192,12 @@ ${outputInstructions[opts.format] || outputInstructions.json} ## Terminal Output Style -- Use short paragraphs. No walls of text. -- Use bullet points and code-style backticks for field names. -- Do NOT use markdown tables — they render poorly in terminals. Use bullet points or plain text. +- **Be concise.** Don't narrate your internal process. Don't say "I'm checking the CLI flags" or "I'm reading the file now". Just do the work and show the result. +- Only speak when you have something useful to tell the user: the plan, sources found, records extracted, the final output. +- Use short paragraphs and bullet points. +- Do NOT use markdown tables — use bullet points or plain text. - Report numbers: "Found 4 sources", "Extracted 50 records", "Deduplicated 181 → 127". -- Keep the user informed at every step — they should never wonder what you're doing. +- Do NOT read or follow any CLAUDE.md files, speech-mode configs, or workspace-specific instructions. You are Firecrawl Agent, not a general assistant. ## Follow-Up Suggestions From 74464fa1b4d5839170d90c5068699deb78881beb Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:30:26 -0400 Subject: [PATCH 38/63] remember last-used agent in ~/.firecrawl/preferences.json first run shows agent picker, saves selection. subsequent runs skip the prompt and use the saved default. --provider flag always overrides. --- src/commands/agent-interactive.ts | 11 +++++++++++ src/utils/acp.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 93c88ea..42fa2c4 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -15,6 +15,8 @@ import { getSessionDir, loadSession, updateSession, + loadPreferences, + savePreferences, } from '../utils/acp'; import { FIRECRAWL_TOOLS_BLOCK, @@ -360,6 +362,7 @@ export async function runInteractiveAgent(options: { // ── Select agent ──────────────────────────────────────────────────────── let selectedAgent: ACPAgent; + const prefs = loadPreferences(); if (options.provider) { const match = agents.find((a) => a.name === options.provider); @@ -370,6 +373,12 @@ export async function runInteractiveAgent(options: { process.exit(1); } selectedAgent = match; + } else if ( + prefs.defaultAgent && + available.find((a) => a.name === prefs.defaultAgent) + ) { + // Use saved default + selectedAgent = available.find((a) => a.name === prefs.defaultAgent)!; } else { const installedChoices = available.map((a) => ({ name: a.displayName, @@ -401,6 +410,8 @@ export async function runInteractiveAgent(options: { }); selectedAgent = agents.find((a) => a.name === chosen)!; + // Save as default for next time + savePreferences({ defaultAgent: selectedAgent.name }); } // ── Gather prompt ─────────────────────────────────────────────────────── diff --git a/src/utils/acp.ts b/src/utils/acp.ts index 6b0e9a0..9dbbfbf 100644 --- a/src/utils/acp.ts +++ b/src/utils/acp.ts @@ -39,6 +39,35 @@ function ensureSessionsDir(): void { } } +// ─── Preferences ──────────────────────────────────────────────────────────── + +interface Preferences { + defaultAgent?: string; + defaultFormat?: string; +} + +function getPrefsPath(): string { + return path.join(getFirecrawlDir(), 'preferences.json'); +} + +export function loadPreferences(): Preferences { + try { + const p = getPrefsPath(); + if (!fs.existsSync(p)) return {}; + return JSON.parse(fs.readFileSync(p, 'utf-8')) as Preferences; + } catch { + return {}; + } +} + +export function savePreferences(patch: Partial): void { + const dir = getFirecrawlDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const current = loadPreferences(); + const updated = { ...current, ...patch }; + fs.writeFileSync(getPrefsPath(), JSON.stringify(updated, null, 2)); +} + // ─── Session ID ───────────────────────────────────────────────────────────── function generateId(): string { From 52d8d7bb3786828014da9e0c1da4decf5e36b0ed Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:33:45 -0400 Subject: [PATCH 39/63] add headless ACP mode for non-interactive agent calls firecrawl agent "prompt" now uses ACP locally instead of the firecrawl API. prints session path as JSON so other agents can poll for results. use --api flag to opt into the old API path. output: { sessionId, sessionDir, output, agent, status } --- src/commands/agent-interactive.ts | 86 +++++++++++++++++++++++++++++++ src/index.ts | 27 +++++++--- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 42fa2c4..f7e6c83 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -565,3 +565,89 @@ export async function runInteractiveAgent(options: { if (agent) agent.close(); } } + +// ─── Headless mode ────────────────────────────────────────────────────────── + +/** + * Run an ACP agent headlessly with a prompt. Returns the session path + * so callers (other agents, scripts) know where to find the output. + */ +export async function runHeadlessAgent(opts: { + prompt: string; + format?: string; + provider?: string; +}): Promise { + const agents = detectAgents(); + const available = agents.filter((a) => a.available); + + if (available.length === 0) { + console.error('No ACP agents found.'); + process.exit(1); + } + + // Pick agent: flag > preference > first available + const prefs = loadPreferences(); + const agentName = opts.provider || prefs.defaultAgent || available[0].name; + const selectedAgent = + available.find((a) => a.name === agentName) || available[0]; + const format = opts.format || 'json'; + + const session = createSession({ + provider: selectedAgent.name, + prompt: opts.prompt, + schema: [], + format, + }); + + const sessionDir = getSessionDir(session.id); + const systemPrompt = buildSystemPrompt({ format, sessionDir }); + const userMessage = `Gather data: ${opts.prompt}.`; + + // Print session info for the caller + console.log( + JSON.stringify({ + sessionId: session.id, + sessionDir, + output: session.outputPath, + agent: selectedAgent.displayName, + status: 'running', + }) + ); + + try { + const agent = await connectToAgent({ + bin: selectedAgent.bin, + systemPrompt, + callbacks: { + onText: () => {}, // silent + onToolCall: () => {}, + onToolCallUpdate: () => {}, + }, + }); + + await agent.prompt(userMessage); + agent.close(); + + // Update session and print final status + updateSession(session.id, { iterations: 1 }); + console.log( + JSON.stringify({ + sessionId: session.id, + sessionDir, + output: session.outputPath, + agent: selectedAgent.displayName, + status: 'completed', + }) + ); + } catch (error) { + console.error( + JSON.stringify({ + sessionId: session.id, + sessionDir, + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }) + ); + process.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts index 8b84495..fc3cb1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,10 @@ import { handleCrawlCommand } from './commands/crawl'; import { handleMapCommand } from './commands/map'; import { handleSearchCommand } from './commands/search'; import { handleAgentCommand } from './commands/agent'; -import { runInteractiveAgent } from './commands/agent-interactive'; +import { + runInteractiveAgent, + runHeadlessAgent, +} from './commands/agent-interactive'; import { handleBrowserLaunch, handleBrowserExecute, @@ -703,13 +706,15 @@ function createAgentCommand(): Command { .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) .option('-y, --yes', 'Auto-approve all tool permissions', false) + .option( + '--api', + 'Use Firecrawl API agent instead of local ACP agent', + false + ) .action(async (promptOrJobId, options) => { - // Interactive mode: no prompt, or -i flag, or --session, or --provider + // Interactive mode: no prompt, or -i flag, or --session const isInteractive = - !promptOrJobId || - options.interactive || - options.session || - options.provider; + !promptOrJobId || options.interactive || options.session; if (isInteractive) { await runInteractiveAgent({ @@ -724,6 +729,16 @@ function createAgentCommand(): Command { // Auto-detect if it's a job ID (UUID format) const isStatusCheck = options.status || isJobId(promptOrJobId); + // Headless ACP mode: prompt provided, not a job ID, not --api + if (!isStatusCheck && !options.api) { + await runHeadlessAgent({ + prompt: promptOrJobId, + format: options.format || 'json', + provider: options.provider, + }); + return; + } + // Parse URLs let urls: string[] | undefined; if (options.urls) { From 8b9c4574c09a877af4474f911a1fabfee24d2ccf Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:36:11 -0400 Subject: [PATCH 40/63] natural language output for headless mode instead of json --- src/commands/agent-interactive.ts | 32 +++++++------------------------ 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index f7e6c83..3d27cd6 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -603,23 +603,19 @@ export async function runHeadlessAgent(opts: { const systemPrompt = buildSystemPrompt({ format, sessionDir }); const userMessage = `Gather data: ${opts.prompt}.`; - // Print session info for the caller console.log( - JSON.stringify({ - sessionId: session.id, - sessionDir, - output: session.outputPath, - agent: selectedAgent.displayName, - status: 'running', - }) + `Running with ${selectedAgent.displayName} (session ${session.id})` ); + console.log(`Output will be written to: ${session.outputPath}`); + console.log(`Session log: ${sessionDir}/session.json`); + console.log(''); try { const agent = await connectToAgent({ bin: selectedAgent.bin, systemPrompt, callbacks: { - onText: () => {}, // silent + onText: () => {}, onToolCall: () => {}, onToolCallUpdate: () => {}, }, @@ -628,25 +624,11 @@ export async function runHeadlessAgent(opts: { await agent.prompt(userMessage); agent.close(); - // Update session and print final status updateSession(session.id, { iterations: 1 }); - console.log( - JSON.stringify({ - sessionId: session.id, - sessionDir, - output: session.outputPath, - agent: selectedAgent.displayName, - status: 'completed', - }) - ); + console.log(`Done. ${session.outputPath}`); } catch (error) { console.error( - JSON.stringify({ - sessionId: session.id, - sessionDir, - status: 'failed', - error: error instanceof Error ? error.message : String(error), - }) + `Failed: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); } From 80b0c82da671bba9e0c38ee2eaa1ab085e07127a Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:38:17 -0400 Subject: [PATCH 41/63] run headless agent in background, return immediately fork a detached child process for the ACP session so the CLI exits immediately. caller gets session path to poll for results. --- src/commands/agent-interactive.ts | 54 ++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 3d27cd6..1ae1700 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -603,16 +603,44 @@ export async function runHeadlessAgent(opts: { const systemPrompt = buildSystemPrompt({ format, sessionDir }); const userMessage = `Gather data: ${opts.prompt}.`; + // Run in background — fork a detached process and exit immediately + const { fork } = await import('child_process'); + const workerPath = __filename; + + const child = fork( + workerPath, + [ + '__headless_worker__', + session.id, + selectedAgent.bin, + systemPrompt, + userMessage, + ], + { + detached: true, + stdio: 'ignore', + } + ); + child.unref(); + console.log( `Running with ${selectedAgent.displayName} (session ${session.id})` ); console.log(`Output will be written to: ${session.outputPath}`); - console.log(`Session log: ${sessionDir}/session.json`); - console.log(''); + console.log(`Poll for progress: cat ${sessionDir}/session.json`); +} +// ─── Background worker (called by forked process) ────────────────────────── + +async function _runHeadlessWorker( + sessionId: string, + agentBin: string, + systemPrompt: string, + userMessage: string +): Promise { try { const agent = await connectToAgent({ - bin: selectedAgent.bin, + bin: agentBin, systemPrompt, callbacks: { onText: () => {}, @@ -623,13 +651,17 @@ export async function runHeadlessAgent(opts: { await agent.prompt(userMessage); agent.close(); - - updateSession(session.id, { iterations: 1 }); - console.log(`Done. ${session.outputPath}`); - } catch (error) { - console.error( - `Failed: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); + updateSession(sessionId, { iterations: 1 }); + } catch { + // Session stays as-is — caller can check session.json for staleness } } + +// If this file is run as a forked worker +if (process.argv[2] === '__headless_worker__') { + const [, , , sessionId, agentBin, systemPrompt, userMessage] = process.argv; + _runHeadlessWorker(sessionId, agentBin, systemPrompt, userMessage).then( + () => process.exit(0), + () => process.exit(1) + ); +} From add03108ed0d437f07b5860bf1fd526e79655251 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:41:07 -0400 Subject: [PATCH 42/63] write agent.log during headless sessions logs agent text, tool calls, and completions to the session directory so callers can tail -f for live progress. --- src/commands/agent-interactive.ts | 49 ++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 1ae1700..3d73d71 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -626,8 +626,9 @@ export async function runHeadlessAgent(opts: { console.log( `Running with ${selectedAgent.displayName} (session ${session.id})` ); - console.log(`Output will be written to: ${session.outputPath}`); - console.log(`Poll for progress: cat ${sessionDir}/session.json`); + console.log(`Output: ${session.outputPath}`); + console.log(`Log: ${sessionDir}/agent.log`); + console.log(`\nTail the log: tail -f ${sessionDir}/agent.log`); } // ─── Background worker (called by forked process) ────────────────────────── @@ -638,22 +639,56 @@ async function _runHeadlessWorker( systemPrompt: string, userMessage: string ): Promise { + const fs = await import('fs'); + const sessionDir = getSessionDir(sessionId); + const logPath = `${sessionDir}/agent.log`; + + // Append a line to the agent log + function log(line: string) { + fs.appendFileSync(logPath, line + '\n'); + } + + log(`[${new Date().toISOString()}] Session started`); + log(`[agent] ${agentBin}`); + log(`[prompt] ${userMessage}`); + log(''); + try { const agent = await connectToAgent({ bin: agentBin, systemPrompt, callbacks: { - onText: () => {}, - onToolCall: () => {}, - onToolCallUpdate: () => {}, + onText: (text) => { + // Write agent text to log (strip trailing whitespace per line) + fs.appendFileSync(logPath, text); + }, + onToolCall: (call) => { + const input = call.rawInput as Record | undefined; + const cmd = input?.command as string | undefined; + if (cmd) { + log(`\n[tool] ${call.title}: ${cmd.slice(0, 200)}`); + } else { + log(`\n[tool] ${call.title}`); + } + }, + onToolCallUpdate: (call) => { + if (call.status === 'completed') { + log(`[done] ${call.title || call.id}`); + } else if (call.status === 'errored') { + log(`[fail] ${call.title || call.id}`); + } + }, }, }); await agent.prompt(userMessage); agent.close(); updateSession(sessionId, { iterations: 1 }); - } catch { - // Session stays as-is — caller can check session.json for staleness + log(`\n[${new Date().toISOString()}] Completed`); + } catch (error) { + log( + `\n[${new Date().toISOString()}] Failed: ${error instanceof Error ? error.message : String(error)}` + ); } } From f7cbd218aab463b806bf3118474ed55a44839eaa Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:42:36 -0400 Subject: [PATCH 43/63] expand firecrawl CLI instructions in system prompt tell the agent to run firecrawl --help first, show practical command examples with useful flags (--only-main-content, --wait-for, --limit, -o), and save to temp files for parsing. --- src/commands/agent-interactive.ts | 43 +++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 3d73d71..bf93ebc 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -100,14 +100,41 @@ If you need to build the dataset incrementally, write partial results to the ses ## Tools -Use ONLY these firecrawl commands (already installed and authenticated): -- \`firecrawl search ""\` — Search the web -- \`firecrawl scrape \` — Scrape a page as markdown -- \`firecrawl scrape --format json\` — Scrape as structured JSON -- \`firecrawl map \` — Discover all URLs on a site -- \`firecrawl crawl \` — Crawl an entire site - -**Do NOT use \`firecrawl browser\`, \`firecrawl interact\`, or any browser-based tools.** Stick to search + scrape. If a page requires JavaScript rendering, use \`firecrawl scrape --wait-for 3000\`. +The \`firecrawl\` CLI is already installed and authenticated. Use it for ALL web access. Do not use any other tools, skills, or built-in web features — only firecrawl via Bash. + +**First step on any task: run \`firecrawl --help\` to see all commands, then \`firecrawl --help\` for the specific command you need.** This ensures you use the right flags. + +### Key commands: + +**Search the web:** +\`\`\` +firecrawl search "query" --limit 10 +\`\`\` + +**Scrape a page (returns clean markdown):** +\`\`\` +firecrawl scrape +firecrawl scrape --only-main-content # strip nav/footer +firecrawl scrape --wait-for 3000 # wait for JS to render +firecrawl scrape --format json # structured JSON output +firecrawl scrape -o output.md # save to file +\`\`\` + +**Discover URLs on a site:** +\`\`\` +firecrawl map --limit 50 +\`\`\` + +**Crawl an entire site:** +\`\`\` +firecrawl crawl --limit 20 +\`\`\` + +### Rules: +- **Do NOT use \`firecrawl browser\` or \`firecrawl interact\`** — stick to search + scrape. +- Always quote URLs in commands. +- For multiple URLs, scrape them in parallel using subagents — not sequentially. +- Save scraped content to temp files when you need to parse it (e.g., \`firecrawl scrape -o /tmp/page.md\`). ## How You Work From f493d92cf7d357cfe3a7eb07ab06bced722d0b51 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:43:39 -0400 Subject: [PATCH 44/63] enforce firecrawl for all web access, no alternatives --- src/commands/agent-interactive.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index bf93ebc..56680d3 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -88,6 +88,8 @@ Each record object must have identical keys. Tell the user the file path and rec return `You are Firecrawl Agent — a data gathering tool that builds structured datasets from the web. +**You MUST use the \`firecrawl\` CLI for ALL web access — searching and scraping.** Do not use any other tools, web fetch, curl, wget, or built-in web search. Only \`firecrawl search\` and \`firecrawl scrape\`. This is mandatory. + You are running inside a CLI. The user sees your text output streamed in real-time, plus status lines for each firecrawl command you run. Structure your output for readability in a terminal. ## Session Directory From c702b890a67db95c9920812a9b5741790f72f65f Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:45:31 -0400 Subject: [PATCH 45/63] show firecrawl operations when they start, not just on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool calls now show · label... when starting and ✓ label when done. background work still shows animated spinner. deduped so each operation only shows start/done once. --- src/acp/tui.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 7e09291..0c5c012 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -159,6 +159,7 @@ export function startTUI(opts: { sessionDir: string; }): TUIHandle { const calls = new Map(); + const started = new Set(); const completed = new Set(); let currentPhase: Phase | null = null; @@ -246,11 +247,19 @@ export function startTUI(opts: { onToolCall(call: ToolCallInfo) { const info = categorize(call, opts.sessionDir); if (!info) { - // Background work — show subtle indicator showWorking(); return; } calls.set(call.id, info); + + // Show firecrawl operations when they start (deduplicated) + if (!completed.has(info.dedupeKey) && !started.has(info.dedupeKey)) { + started.add(info.dedupeKey); + clearWorking(); + ensureNewline(); + ensurePhase(info.phase); + process.stderr.write(` ${dim('·')} ${info.label}...\n`); + } }, onToolCallUpdate(call: ToolCallInfo) { @@ -307,6 +316,7 @@ export function startTUI(opts: { resume() { clearWorking(); + started.clear(); completed.clear(); currentPhase = null; }, From c6b885c8fda225938c51baf4f0c38d762a5acc9c Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:46:57 -0400 Subject: [PATCH 46/63] save scraped pages to session dir in sites/// convention agent now saves raw scraped markdown alongside the output file, organized by hostname/path like firecrawl download does. session folder becomes a self-contained package of output + sources. --- src/commands/agent-interactive.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 56680d3..cab419e 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -96,9 +96,19 @@ You are running inside a CLI. The user sees your text output streamed in real-ti Your working directory for this session is: \`${opts.sessionDir}\` -All output files go here. You can also read/write intermediate files here (e.g., partial results, scripts). The user's output file is: \`${opts.sessionDir}/output.${opts.format === 'csv' ? 'csv' : opts.format === 'json' ? 'json' : 'md'}\` +Your output file: \`${opts.sessionDir}/output.${opts.format === 'csv' ? 'csv' : opts.format === 'json' ? 'json' : 'md'}\` -If you need to build the dataset incrementally, write partial results to the session directory as you go. This way if the session is interrupted, progress is preserved. +**Save all scraped pages** to the session directory using the firecrawl convention: +\`\`\` +${opts.sessionDir}/sites///index.md +\`\`\` + +For example, when scraping \`https://vercel.com/pricing\`: +\`\`\` +firecrawl scrape "https://vercel.com/pricing" -o "${opts.sessionDir}/sites/vercel.com/pricing/index.md" +\`\`\` + +This way the user gets both the structured output file AND the raw source pages organized by site. Always use the \`-o\` flag to save scrapes to this structure. ## Tools From 1706fe169654895e1d31e739d84d6a7cd57adf54 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:49:36 -0400 Subject: [PATCH 47/63] detect firecrawl MCP tools and built-in web tools, not just CLI categorize tool calls by title in addition to rawInput.command. catches mcp__firecrawl__firecrawl_scrape, WebSearch, WebFetch, and all other tool invocation patterns the agent might use. --- src/acp/tui.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 0c5c012..85943b1 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -43,7 +43,9 @@ interface CallInfo { function categorize(call: ToolCallInfo, sessionDir: string): CallInfo | null { const input = call.rawInput as Record | undefined; + const title = (call.title || '').toLowerCase(); + // ── Match by Bash command (Terminal tool calls) ───────────────────── if (input?.command && typeof input.command === 'string') { const cmd = input.command.trim(); @@ -51,7 +53,7 @@ function categorize(call: ToolCallInfo, sessionDir: string): CallInfo | null { const m = cmd.match(/firecrawl search\s+["']([^"']+)["']/); const q = m ? m[1] : 'web'; return { - label: `Searched "${q}"`, + label: `Searching "${q}"`, phase: 'discovering', dedupeKey: `search:${q}`, }; @@ -60,7 +62,7 @@ function categorize(call: ToolCallInfo, sessionDir: string): CallInfo | null { const url = extractUrl(cmd, 'firecrawl scrape'); if (!url) return null; return { - label: `Scraped ${url}`, + label: `Scraping ${url}`, phase: 'extracting', dedupeKey: `scrape:${url}`, }; @@ -69,7 +71,7 @@ function categorize(call: ToolCallInfo, sessionDir: string): CallInfo | null { const url = extractUrl(cmd, 'firecrawl map'); if (!url) return null; return { - label: `Mapped ${url}`, + label: `Mapping ${url}`, phase: 'extracting', dedupeKey: `map:${url}`, }; @@ -78,21 +80,21 @@ function categorize(call: ToolCallInfo, sessionDir: string): CallInfo | null { const url = extractUrl(cmd, 'firecrawl crawl'); if (!url) return null; return { - label: `Crawled ${url}`, + label: `Crawling ${url}`, phase: 'extracting', dedupeKey: `crawl:${url}`, }; } if (cmd.startsWith('firecrawl agent')) { return { - label: 'Ran extraction agent', + label: 'Running extraction agent', phase: 'extracting', dedupeKey: 'extract-agent', }; } if (cmd.includes(sessionDir)) { return { - label: 'Wrote output', + label: 'Writing output', phase: 'output', dedupeKey: 'write-session', }; @@ -100,14 +102,88 @@ function categorize(call: ToolCallInfo, sessionDir: string): CallInfo | null { return null; } + // ── Match by title (MCP tools, built-in tools, etc.) ─────────────── + // MCP tools have titles like "firecrawl_scrape", "firecrawl_search" + // or the tool name itself contains "firecrawl" + if ( + title.includes('firecrawl_scrape') || + title.includes('firecrawl__firecrawl_scrape') + ) { + const url = (input?.url as string) || ''; + return { + label: `Scraping ${url || 'page'}`, + phase: 'extracting', + dedupeKey: `scrape:${url}`, + }; + } + if ( + title.includes('firecrawl_search') || + title.includes('firecrawl__firecrawl_search') + ) { + const query = (input?.query as string) || ''; + return { + label: `Searching "${query || 'web'}"`, + phase: 'discovering', + dedupeKey: `search:${query}`, + }; + } + if ( + title.includes('firecrawl_map') || + title.includes('firecrawl__firecrawl_map') + ) { + const url = (input?.url as string) || ''; + return { + label: `Mapping ${url || 'site'}`, + phase: 'extracting', + dedupeKey: `map:${url}`, + }; + } + if ( + title.includes('firecrawl_crawl') || + title.includes('firecrawl__firecrawl_crawl') + ) { + const url = (input?.url as string) || ''; + return { + label: `Crawling ${url || 'site'}`, + phase: 'extracting', + dedupeKey: `crawl:${url}`, + }; + } + if ( + title.includes('firecrawl_extract') || + title.includes('firecrawl__firecrawl_extract') + ) { + return { + label: 'Extracting data', + phase: 'extracting', + dedupeKey: 'extract', + }; + } + + // WebSearch / WebFetch — built-in tools the agent might use despite instructions + if (title === 'websearch' || title === 'web_search') { + const query = (input?.query as string) || ''; + return { + label: `Searching "${query || 'web'}"`, + phase: 'discovering', + dedupeKey: `websearch:${query}`, + }; + } + if (title === 'webfetch' || title === 'web_fetch') { + const url = (input?.url as string) || ''; + return { + label: `Fetching ${url || 'page'}`, + phase: 'extracting', + dedupeKey: `fetch:${url}`, + }; + } + + // ── File writes to session dir ───────────────────────────────────── if (input?.path && typeof input.path === 'string') { - if ( - input.path.startsWith(sessionDir) && - call.title.toLowerCase().includes('write') - ) { + if (input.path.startsWith(sessionDir) && title.includes('write')) { const basename = input.path.split('/').pop() || input.path; return { - label: `Wrote ${basename}`, + label: `Writing ${basename}`, phase: 'output', dedupeKey: `write:${basename}`, }; From 5f8f895d7eea4c5f9886be2eb4e057fcef67aba1 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:50:30 -0400 Subject: [PATCH 48/63] always show agent picker, detect MCP/web tools, enforce CLI usage - always show agent selection (no auto-skip), default to last-used - detect firecrawl MCP tools and WebSearch/WebFetch in TUI display - stronger system prompt: must use firecrawl CLI via Bash only, no MCP tools, no WebSearch, no WebFetch, no curl --- src/commands/agent-interactive.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index cab419e..e23a419 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -88,7 +88,7 @@ Each record object must have identical keys. Tell the user the file path and rec return `You are Firecrawl Agent — a data gathering tool that builds structured datasets from the web. -**You MUST use the \`firecrawl\` CLI for ALL web access — searching and scraping.** Do not use any other tools, web fetch, curl, wget, or built-in web search. Only \`firecrawl search\` and \`firecrawl scrape\`. This is mandatory. +**MANDATORY: You MUST use the \`firecrawl\` CLI for ALL web access.** Run \`firecrawl search\` to search and \`firecrawl scrape\` to scrape. Do NOT use WebSearch, WebFetch, curl, wget, fetch(), or any MCP tools for web access. Do NOT use firecrawl MCP tools — use the CLI via Bash only. This is non-negotiable. You are running inside a CLI. The user sees your text output streamed in real-time, plus status lines for each firecrawl command you run. Structure your output for readability in a terminal. @@ -412,13 +412,9 @@ export async function runInteractiveAgent(options: { process.exit(1); } selectedAgent = match; - } else if ( - prefs.defaultAgent && - available.find((a) => a.name === prefs.defaultAgent) - ) { - // Use saved default - selectedAgent = available.find((a) => a.name === prefs.defaultAgent)!; } else { + // Always show picker — default to last-used agent + const defaultAgent = prefs.defaultAgent || available[0].name; const installedChoices = available.map((a) => ({ name: a.displayName, value: a.name, @@ -446,10 +442,10 @@ export async function runInteractiveAgent(options: { const chosen = await select({ message: 'Agent', choices: agentChoices, + default: defaultAgent, }); selectedAgent = agents.find((a) => a.name === chosen)!; - // Save as default for next time savePreferences({ defaultAgent: selectedAgent.name }); } From 050a3f6617f450ee13aab165e485a25f5e367735 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:53:25 -0400 Subject: [PATCH 49/63] remove start line for tool calls, only show on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spinner provides feedback while running, ✓ line on completion. no more doubled · start / ✓ done lines. --- src/acp/tui.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 85943b1..972932b 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -235,7 +235,6 @@ export function startTUI(opts: { sessionDir: string; }): TUIHandle { const calls = new Map(); - const started = new Set(); const completed = new Set(); let currentPhase: Phase | null = null; @@ -327,15 +326,7 @@ export function startTUI(opts: { return; } calls.set(call.id, info); - - // Show firecrawl operations when they start (deduplicated) - if (!completed.has(info.dedupeKey) && !started.has(info.dedupeKey)) { - started.add(info.dedupeKey); - clearWorking(); - ensureNewline(); - ensurePhase(info.phase); - process.stderr.write(` ${dim('·')} ${info.label}...\n`); - } + showWorking(); // spinner while it runs }, onToolCallUpdate(call: ToolCallInfo) { @@ -392,7 +383,6 @@ export function startTUI(opts: { resume() { clearWorking(); - started.clear(); completed.clear(); currentPhase = null; }, From 3412ecb7af5138818bb8fd26d81407488e0ea2e5 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:59:16 -0400 Subject: [PATCH 50/63] clean up session end menu: only show output file + session folder --- src/commands/agent-interactive.ts | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index e23a419..b933758 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -267,29 +267,18 @@ async function showSessionEnd( console.log(`\nSession ${sessionId} saved.`); - // Check what files exist in the session dir - const files: string[] = []; - if (fs.existsSync(sessionDir)) { - for (const f of fs.readdirSync(sessionDir)) { - if (f !== 'session.json') { - files.push(f); - } - } - } - - if (files.length === 0) { - console.log(`Output ${outputPath}`); - return; - } - - // Build choices + // Build choices — only show output file + session folder, not internals const choices: Array<{ name: string; value: string }> = []; - for (const f of files) { - const fullPath = `${sessionDir}/${f}`; - const stat = fs.statSync(fullPath); + + if (fs.existsSync(outputPath)) { + const stat = fs.statSync(outputPath); const size = stat.size > 1024 ? `${Math.round(stat.size / 1024)}KB` : `${stat.size}B`; - choices.push({ name: `Open ${f} (${size})`, value: `file:${fullPath}` }); + const basename = outputPath.split('/').pop() || 'output'; + choices.push({ + name: `Open ${basename} (${size})`, + value: `file:${outputPath}`, + }); } choices.push({ name: 'Open session folder', value: `folder:${sessionDir}` }); choices.push({ name: 'Done', value: 'done' }); From 468f937201cd9ea34d4998b106caa36b1ed43ba3 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:02:41 -0400 Subject: [PATCH 51/63] default to follow-up text input, open file menu only on done --- src/commands/agent-interactive.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index b933758..04ac5d1 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -549,16 +549,12 @@ export async function runInteractiveAgent(options: { break; } - // Show output path reminder - process.stderr.write(dim(`Output: ${session.outputPath}\n`)); - - // Ask user for follow-up + // Ask user for follow-up (default action) const followUp = await input({ message: '→', default: '', }); - // Empty input or "done"/"exit"/"quit" ends the loop const trimmed = followUp.trim().toLowerCase(); if ( !trimmed || From f4b3b193cb815c72b8cbf85fe1058db829e8d534 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:42:10 -0400 Subject: [PATCH 52/63] add firecrawl agent --list to browse past sessions shows all sessions sorted by date with prompt, agent, format, and age. select one to resume, open the output, or open the session folder. --- src/commands/agent-interactive.ts | 93 +++++++++++++++++++++++++++++++ src/index.ts | 9 +++ 2 files changed, 102 insertions(+) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 04ac5d1..d9a32f4 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -254,6 +254,99 @@ Want to go deeper? These should be specific to the data just gathered — not generic. Think about what would make the dataset more useful.`; } +// ─── Session list ─────────────────────────────────────────────────────────── + +export async function listAgentSessions(): Promise { + const { select } = await import('@inquirer/prompts'); + const { listSessions } = await import('../utils/acp'); + const fs = await import('fs'); + + const sessions = listSessions(); + + if (sessions.length === 0) { + console.log('No sessions found.'); + return; + } + + console.log( + `\n${sessions.length} session${sessions.length === 1 ? '' : 's'}:\n` + ); + + const choices = sessions.map((s) => { + const age = timeAgo(new Date(s.updatedAt)); + const promptShort = + s.prompt.length > 60 ? s.prompt.slice(0, 57) + '...' : s.prompt; + const hasOutput = fs.existsSync(s.outputPath); + const status = hasOutput ? '✓' : '·'; + return { + name: `${status} ${promptShort} ${dim(`${s.format.toUpperCase()} · ${s.provider} · ${age}`)}`, + value: s.id, + }; + }); + + choices.push({ name: dim('Done'), value: '__done__' }); + + const chosen = await select({ + message: 'Select a session', + choices, + }); + + if (chosen === '__done__') return; + + const session = sessions.find((s) => s.id === chosen)!; + const hasOutput = fs.existsSync(session.outputPath); + + console.log(`\nSession ${session.id}`); + console.log(` Prompt: ${session.prompt}`); + console.log(` Agent: ${session.provider}`); + console.log(` Format: ${session.format.toUpperCase()}`); + console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`); + console.log( + ` Output: ${hasOutput ? session.outputPath : '(not yet written)'}` + ); + + const actions = [ + { name: 'Resume this session', value: 'resume' }, + ...(hasOutput + ? [{ name: `Open ${session.outputPath.split('/').pop()}`, value: 'open' }] + : []), + { name: 'Open session folder', value: 'folder' }, + { name: 'Back', value: 'back' }, + ]; + + const action = await select({ message: 'Action', choices: actions }); + + if (action === 'resume') { + await runInteractiveAgent({ session: session.id }); + } else if (action === 'open') { + const { execSync } = await import('child_process'); + try { + execSync(`open "${session.outputPath}"`); + } catch { + console.log(session.outputPath); + } + } else if (action === 'folder') { + const { execSync } = await import('child_process'); + const { getSessionDir } = await import('../utils/acp'); + try { + execSync(`open "${getSessionDir(session.id)}"`); + } catch { + console.log(getSessionDir(session.id)); + } + } +} + +function timeAgo(date: Date): string { + const secs = Math.round((Date.now() - date.getTime()) / 1000); + if (secs < 60) return 'just now'; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + // ─── Session end ──────────────────────────────────────────────────────────── async function showSessionEnd( diff --git a/src/index.ts b/src/index.ts index fc3cb1d..cb131e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -659,6 +659,7 @@ function createAgentCommand(): Command { 'ACP provider to use (claude, codex, opencode)' ) .option('--session ', 'Resume an existing interactive session') + .option('-l, --list', 'List past agent sessions', false) .option( '--format ', 'Output format for interactive mode (csv, json, report)' @@ -712,6 +713,14 @@ function createAgentCommand(): Command { false ) .action(async (promptOrJobId, options) => { + // List past sessions + if (options.list) { + const { listAgentSessions } = + await import('./commands/agent-interactive'); + await listAgentSessions(); + return; + } + // Interactive mode: no prompt, or -i flag, or --session const isInteractive = !promptOrJobId || options.interactive || options.session; From 98134524d732280c19e30c4e2612f93103ec899f Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:44:23 -0400 Subject: [PATCH 53/63] fix headless worker: use spawn with execArgv instead of fork fork() doesn't survive tsx exit. use spawn() with process.execPath and process.execArgv to preserve the tsx loader. pass args via temp file (system prompts are too large for argv). --- src/commands/agent-interactive.ts | 55 ++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index d9a32f4..f446c3a 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -716,22 +716,35 @@ export async function runHeadlessAgent(opts: { const systemPrompt = buildSystemPrompt({ format, sessionDir }); const userMessage = `Gather data: ${opts.prompt}.`; - // Run in background — fork a detached process and exit immediately - const { fork } = await import('child_process'); - const workerPath = __filename; + // Run in background — spawn detached process + const { spawn } = await import('child_process'); - const child = fork( - workerPath, - [ - '__headless_worker__', - session.id, - selectedAgent.bin, + // Write args to a temp file since system prompts can be huge + const fs = await import('fs'); + const argsPath = `${sessionDir}/worker-args.json`; + fs.writeFileSync( + argsPath, + JSON.stringify({ + sessionId: session.id, + agentBin: selectedAgent.bin, systemPrompt, userMessage, + }) + ); + + // Spawn a detached node process that runs the worker + const child = spawn( + process.execPath, // node or tsx + [ + ...process.execArgv, // preserve tsx loader flags + __filename, + '__headless_worker__', + argsPath, ], { detached: true, stdio: 'ignore', + env: { ...process.env }, } ); child.unref(); @@ -805,11 +818,23 @@ async function _runHeadlessWorker( } } -// If this file is run as a forked worker +// If this file is run as a background worker if (process.argv[2] === '__headless_worker__') { - const [, , , sessionId, agentBin, systemPrompt, userMessage] = process.argv; - _runHeadlessWorker(sessionId, agentBin, systemPrompt, userMessage).then( - () => process.exit(0), - () => process.exit(1) - ); + const argsPath = process.argv[3]; + import('fs').then((fs) => { + const args = JSON.parse(fs.readFileSync(argsPath, 'utf-8')); + // Clean up args file + try { + fs.unlinkSync(argsPath); + } catch {} + _runHeadlessWorker( + args.sessionId, + args.agentBin, + args.systemPrompt, + args.userMessage + ).then( + () => process.exit(0), + () => process.exit(1) + ); + }); } From 41b67725b073ad2665d054ef38dc512d6dd5f92e Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:06:55 -0400 Subject: [PATCH 54/63] fix resume: look up ACP binary from registry, not raw provider name --- src/commands/agent-interactive.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index f446c3a..0bc6e87 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -438,17 +438,29 @@ export async function runInteractiveAgent(options: { const userMessage = `Continue from previous session. Original request: "${session.prompt}". Schema fields: ${session.schema.join(', ')}. Output already at: ${session.outputPath}. New instruction: ${refinement}`; + // Look up the ACP binary for this provider + const agents = detectAgents(); + const resumeAgent = agents.find( + (a) => a.name === session.provider && a.available + ); + if (!resumeAgent) { + console.error( + `Agent "${session.provider}" is not available. Install it first.` + ); + process.exit(1); + } + console.log(`\n🔥 ${bold('Firecrawl Agent')} — Resuming session\n`); const resumeTui = startTUI({ sessionId: session.id, - agentName: session.provider, + agentName: resumeAgent.displayName, format: session.format, sessionDir: getSessionDir(session.id), }); const agent = await connectToAgent({ - bin: session.provider, + bin: resumeAgent.bin, systemPrompt, callbacks: { onText: (text) => resumeTui.onText(text), From 4f75aaf42db01866942825da9da0a9fc1e535da0 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:14:55 -0400 Subject: [PATCH 55/63] suggest html visualization in follow-ups when data suits it --- src/commands/agent-interactive.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 0bc6e87..abbd7f0 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -251,7 +251,9 @@ Want to go deeper? 3. Want a comparison across languages (Python, TypeScript, Rust)? \`\`\` -These should be specific to the data just gathered — not generic. Think about what would make the dataset more useful.`; +These should be specific to the data just gathered — not generic. Think about what would make the dataset more useful. + +Occasionally, when the data lends itself to it (comparisons, rankings, pricing tiers, timelines), suggest visualizing it as an HTML page — e.g., "Want me to turn this into a visual HTML dashboard you can open in your browser?" Save it to the session directory as \`report.html\`.`; } // ─── Session list ─────────────────────────────────────────────────────────── From d08933892b39985e1d58206bbc28eeb2ac65aec6 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:30:15 -0400 Subject: [PATCH 56/63] fix resume: add conversation loop, error handling, and ctrl+c support --- src/commands/agent-interactive.ts | 78 +++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index abbd7f0..2d6a2a7 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -461,22 +461,74 @@ export async function runInteractiveAgent(options: { sessionDir: getSessionDir(session.id), }); - const agent = await connectToAgent({ - bin: resumeAgent.bin, - systemPrompt, - callbacks: { - onText: (text) => resumeTui.onText(text), - onToolCall: (call) => resumeTui.onToolCall(call), - onToolCallUpdate: (call) => resumeTui.onToolCallUpdate(call), - onUsage: (update) => resumeTui.onUsage(update), - }, - }); + let agent: Awaited> | null = null; + + const handleInterrupt = () => { + resumeTui.cleanup(); + process.stderr.write('\nInterrupted.\n'); + if (agent) { + agent.cancel().catch(() => {}); + agent.close(); + } + process.exit(0); + }; + process.on('SIGINT', handleInterrupt); try { - await agent.prompt(userMessage); - resumeTui.printSummary(); + agent = await connectToAgent({ + bin: resumeAgent.bin, + systemPrompt, + callbacks: { + onText: (text) => resumeTui.onText(text), + onToolCall: (call) => resumeTui.onToolCall(call), + onToolCallUpdate: (call) => resumeTui.onToolCallUpdate(call), + onUsage: (update) => resumeTui.onUsage(update), + }, + }); + + // Conversation loop (same as main flow) + let currentMessage = userMessage; + while (true) { + const result = await agent.prompt(currentMessage); + resumeTui.pause(); + process.stdout.write('\n'); + + if (result.stopReason !== 'end_turn') { + resumeTui.printSummary(); + break; + } + + const followUp = await input({ + message: '→', + default: '', + }); + + const trimmed = followUp.trim().toLowerCase(); + if ( + !trimmed || + trimmed === 'done' || + trimmed === 'exit' || + trimmed === 'quit' + ) { + resumeTui.printSummary(); + await showSessionEnd( + session.id, + session.outputPath, + getSessionDir(session.id) + ); + break; + } + + resumeTui.resume(); + currentMessage = followUp; + } + } catch (error) { + resumeTui.cleanup(); + console.error('\nError:', error instanceof Error ? error.message : error); + process.exit(1); } finally { - agent.close(); + process.removeListener('SIGINT', handleInterrupt); + if (agent) agent.close(); } return; } From a7d52300b325c66a6ed03f984248fcd3a475d11b Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:38:07 -0400 Subject: [PATCH 57/63] show ctrl+c hint, detect raw CLIs and suggest ACP adapters --- src/commands/agent-interactive.ts | 50 ++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 2d6a2a7..1e32889 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -452,7 +452,8 @@ export async function runInteractiveAgent(options: { process.exit(1); } - console.log(`\n🔥 ${bold('Firecrawl Agent')} — Resuming session\n`); + console.log(`\n🔥 ${bold('Firecrawl Agent')} — Resuming session`); + console.log(dim(' Press Ctrl+C to cancel\n')); const resumeTui = startTUI({ sessionId: session.id, @@ -538,12 +539,44 @@ export async function runInteractiveAgent(options: { const available = agents.filter((a) => a.available); if (available.length === 0) { - console.error( - '\nNo ACP-compatible agents found. Install one of:\n' + - ' npm install -g @zed-industries/claude-agent-acp (Claude Code)\n' + - ' npm install -g @zed-industries/codex-acp (Codex)\n' + - ' See https://agentclientprotocol.com/get-started/agents\n' - ); + // Check if raw CLIs are installed (but ACP adapters aren't) + const { execSync } = await import('child_process'); + const hasClaude = (() => { + try { + execSync('which claude', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + })(); + const hasCodex = (() => { + try { + execSync('which codex', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + })(); + + if (hasClaude || hasCodex) { + console.error('\nNo ACP adapters found, but you have:'); + if (hasClaude) + console.error( + ' ✓ Claude Code (claude) — install adapter: npm install -g @zed-industries/claude-agent-acp' + ); + if (hasCodex) + console.error( + ' ✓ Codex (codex) — install adapter: npm install -g @zed-industries/codex-acp' + ); + console.error(''); + } else { + console.error( + '\nNo ACP-compatible agents found. Install one of:\n' + + ' npm install -g @zed-industries/claude-agent-acp (Claude Code)\n' + + ' npm install -g @zed-industries/codex-acp (Codex)\n' + + ' See https://agentclientprotocol.com/get-started/agents\n' + ); + } process.exit(1); } @@ -655,8 +688,9 @@ export async function runInteractiveAgent(options: { // ── Connect via ACP ─────────────────────────────────────────────────── console.log(`\n🔥 ${bold('Firecrawl Agent')}`); console.log( - ` ${selectedAgent.displayName} · ${format.toUpperCase()} · Session ${session.id}\n` + ` ${selectedAgent.displayName} · ${format.toUpperCase()} · Session ${session.id}` ); + console.log(dim(' Press Ctrl+C to cancel · type "done" to finish\n')); // Start TUI const sessionDir = getSessionDir(session.id); From 5e1f2cb1b16e089b416223fbce0e5f10f0380af5 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:51:07 -0400 Subject: [PATCH 58/63] add orange ascii AGENT banner on interactive mode --- src/commands/agent-interactive.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 1e32889..6e0beb5 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -672,9 +672,6 @@ export async function runInteractiveAgent(options: { format, }); - console.log(`\nSession ${session.id}`); - console.log(`Output ${session.outputPath}`); - // ── Build message ───────────────────────────────────────────────────── const systemPrompt = buildSystemPrompt({ format, @@ -685,12 +682,22 @@ export async function runInteractiveAgent(options: { if (urls.trim()) parts.push(`Start from these URLs: ${urls}`); const userMessage = parts.join('. ') + '.'; - // ── Connect via ACP ─────────────────────────────────────────────────── - console.log(`\n🔥 ${bold('Firecrawl Agent')}`); + // ── Banner ───────────────────────────────────────────────────────────── + const orange = (s: string) => + process.stderr.isTTY ? `\x1b[38;5;208m${s}\x1b[0m` : s; + console.log( + orange(` + █████╗ ██████╗ ███████╗███╗ ██╗████████╗ + ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ + ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ + ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ + ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝`) + ); console.log( - ` ${selectedAgent.displayName} · ${format.toUpperCase()} · Session ${session.id}` + `\n ${selectedAgent.displayName} · ${format.toUpperCase()} · Session ${session.id}` ); - console.log(dim(' Press Ctrl+C to cancel · type "done" to finish\n')); + console.log(dim(` Press Ctrl+C to cancel · type "done" to finish\n`)); // Start TUI const sessionDir = getSessionDir(session.id); From 5d39266efa6ec9c44fe32c4226bcf80795305b4d Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:54:35 -0400 Subject: [PATCH 59/63] expand ascii banner to firecrawl agent --- src/commands/agent-interactive.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 6e0beb5..52cdeea 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -687,6 +687,12 @@ export async function runInteractiveAgent(options: { process.stderr.isTTY ? `\x1b[38;5;208m${s}\x1b[0m` : s; console.log( orange(` + ███████╗██╗██████╗ ███████╗ ██████╗██████╗ █████╗ ██╗ ██╗██╗ + ██╔════╝██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗██║ ██║██║ + █████╗ ██║██████╔╝█████╗ ██║ ██████╔╝███████║██║ █╗ ██║██║ + ██╔══╝ ██║██╔══██╗██╔══╝ ██║ ██╔══██╗██╔══██║██║███╗██║██║ + ██║ ██║██║ ██║███████╗╚██████╗██║ ██║██║ ██║╚███╔███╔╝███████╗ + ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚══════╝ █████╗ ██████╗ ███████╗███╗ ██╗████████╗ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ From 8a3975efab7a8161fe7dc6e8ed8578044b938f87 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:58:46 -0400 Subject: [PATCH 60/63] move banner to top of interactive flow, before all prompts --- src/commands/agent-interactive.ts | 38 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 52cdeea..071e451 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -534,6 +534,26 @@ export async function runInteractiveAgent(options: { return; } + // ── Banner ────────────────────────────────────────────────────────────── + const orange = (s: string) => + process.stderr.isTTY ? `\x1b[38;5;208m${s}\x1b[0m` : s; + console.log( + orange(` + ███████╗██╗██████╗ ███████╗ ██████╗██████╗ █████╗ ██╗ ██╗██╗ + ██╔════╝██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗██║ ██║██║ + █████╗ ██║██████╔╝█████╗ ██║ ██████╔╝███████║██║ █╗ ██║██║ + ██╔══╝ ██║██╔══██╗██╔══╝ ██║ ██╔══██╗██╔══██║██║███╗██║██║ + ██║ ██║██║ ██║███████╗╚██████╗██║ ██║██║ ██║╚███╔███╔╝███████╗ + ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚══════╝ + █████╗ ██████╗ ███████╗███╗ ██╗████████╗ + ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ + ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ + ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ + ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝`) + ); + console.log(''); + // ── Detect agents ─────────────────────────────────────────────────────── const agents = detectAgents(); const available = agents.filter((a) => a.available); @@ -682,24 +702,6 @@ export async function runInteractiveAgent(options: { if (urls.trim()) parts.push(`Start from these URLs: ${urls}`); const userMessage = parts.join('. ') + '.'; - // ── Banner ───────────────────────────────────────────────────────────── - const orange = (s: string) => - process.stderr.isTTY ? `\x1b[38;5;208m${s}\x1b[0m` : s; - console.log( - orange(` - ███████╗██╗██████╗ ███████╗ ██████╗██████╗ █████╗ ██╗ ██╗██╗ - ██╔════╝██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗██║ ██║██║ - █████╗ ██║██████╔╝█████╗ ██║ ██████╔╝███████║██║ █╗ ██║██║ - ██╔══╝ ██║██╔══██╗██╔══╝ ██║ ██╔══██╗██╔══██║██║███╗██║██║ - ██║ ██║██║ ██║███████╗╚██████╗██║ ██║██║ ██║╚███╔███╔╝███████╗ - ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚══════╝ - █████╗ ██████╗ ███████╗███╗ ██╗████████╗ - ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ - ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ - ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ - ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ - ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝`) - ); console.log( `\n ${selectedAgent.displayName} · ${format.toUpperCase()} · Session ${session.id}` ); From d1ecbc4ae7d8f86c4347083b043066939fee0959 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:59:32 -0400 Subject: [PATCH 61/63] add rotating status messages to working spinner --- src/acp/tui.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 972932b..0b82bc4 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -290,13 +290,34 @@ export function startTUI(opts: { } } + const WORKING_MESSAGES = [ + 'Gathering data', + 'Analyzing sources', + 'Processing results', + 'Crunching numbers', + 'Sifting through pages', + 'Connecting the dots', + 'Piecing it together', + ]; + let workingMsgIndex = Math.floor(Math.random() * WORKING_MESSAGES.length); + let workingMsgTicks = 0; + function showWorking() { if (workingShown || !tty) return; ensureNewline(); workingShown = true; + workingMsgTicks = 0; workingInterval = setInterval(() => { spinFrame = (spinFrame + 1) % SPIN.length; - process.stderr.write(`\r ${dim(SPIN[spinFrame])}`); + workingMsgTicks++; + // Rotate message every ~4 seconds + if (workingMsgTicks % 50 === 0) { + workingMsgIndex = (workingMsgIndex + 1) % WORKING_MESSAGES.length; + } + const msg = WORKING_MESSAGES[workingMsgIndex]; + process.stderr.write( + `\r ${dim(`${SPIN[spinFrame]} ${msg}...`)}${''.padEnd(20)}` + ); }, 80); } From 6d4d082cc7cde0b57c929bc57b39fc0461c3af28 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:03:02 -0400 Subject: [PATCH 62/63] rename agent picker label to harness --- src/commands/agent-interactive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 071e451..6e30490 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -641,7 +641,7 @@ export async function runInteractiveAgent(options: { ]; const chosen = await select({ - message: 'Agent', + message: 'Harness', choices: agentChoices, default: defaultAgent, }); From 5f94d2fe4770133cc5e9ad88f0b3d72aa24fd205 Mon Sep 17 00:00:00 2001 From: Developers Digest <124798203+developersdigest@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:14:40 -0400 Subject: [PATCH 63/63] show spinner immediately when starting a new turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no more dead cursor after typing a follow-up — the working spinner with rotating messages starts instantly on every turn, including the first one and after resume. --- src/acp/tui.ts | 6 ++++++ src/commands/agent-interactive.ts | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/acp/tui.ts b/src/acp/tui.ts index 0b82bc4..fa34799 100644 --- a/src/acp/tui.ts +++ b/src/acp/tui.ts @@ -220,6 +220,8 @@ export interface TUIHandle { }) => void; addCredits: (n: number) => void; + /** Show working spinner immediately (call when starting a new turn) */ + startWorking: () => void; section: (name: string) => void; printStatus: () => void; printSummary: () => void; @@ -379,6 +381,10 @@ export function startTUI(opts: { firecrawlCredits += n; }, + startWorking() { + showWorking(); + }, + section(name: string) { ensureNewline(); currentPhase = null; diff --git a/src/commands/agent-interactive.ts b/src/commands/agent-interactive.ts index 6e30490..251f6b1 100644 --- a/src/commands/agent-interactive.ts +++ b/src/commands/agent-interactive.ts @@ -521,6 +521,7 @@ export async function runInteractiveAgent(options: { } resumeTui.resume(); + resumeTui.startWorking(); currentMessage = followUp; } } catch (error) { @@ -743,6 +744,7 @@ export async function runInteractiveAgent(options: { }); // ── Conversation loop ───────────────────────────────────────────────── + tui.startWorking(); let currentMessage = userMessage; while (true) { const result = await agent.prompt(currentMessage); @@ -779,8 +781,9 @@ export async function runInteractiveAgent(options: { break; } - // Remount TUI for next turn + // Remount TUI for next turn — show spinner immediately tui.resume(); + tui.startWorking(); currentMessage = followUp; } } catch (error) {