diff --git a/src/__tests__/utils/spinner.test.ts b/src/__tests__/utils/spinner.test.ts new file mode 100644 index 0000000000..a2c18094a6 --- /dev/null +++ b/src/__tests__/utils/spinner.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createProgressLine, createSpinner } from '../../utils/spinner'; + +describe('terminal progress output', () => { + let writes: string[]; + const originalIsTTY = process.stderr.isTTY; + + const setTTY = (value: boolean) => { + Object.defineProperty(process.stderr, 'isTTY', { + value, + configurable: true, + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubEnv('TERM', 'xterm-256color'); + writes = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + writes.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + setTTY(originalIsTTY); + }); + + describe('when stderr is not a terminal', () => { + beforeEach(() => setTTY(false)); + + it('prints each distinct spinner message once, with no frames or escapes', () => { + const spinner = createSpinner('Starting agent...'); + spinner.start(); + spinner.update('Agent running... (Job ID: abc)'); + vi.advanceTimersByTime(5000); + spinner.update('Agent running... (Job ID: abc)'); + spinner.update('Agent running... (Job ID: abc)'); + spinner.succeed('Agent completed'); + + expect(writes).toEqual([ + 'Starting agent...\n', + 'Agent running... (Job ID: abc)\n', + '✓ Agent completed\n', + ]); + }); + + it('prints a progress line only when it changes', () => { + const progress = createProgressLine(); + progress.update('Progress: 0/2 pages (scraping)'); + progress.update('Progress: 0/2 pages (scraping)'); + progress.update('Progress: 2/2 pages (completed)'); + progress.end(); + + expect(writes).toEqual([ + 'Progress: 0/2 pages (scraping)\n', + 'Progress: 2/2 pages (completed)\n', + ]); + }); + }); + + it('treats TERM=dumb as non-interactive', () => { + setTTY(true); + vi.stubEnv('TERM', 'dumb'); + const spinner = createSpinner('Working...'); + spinner.start(); + vi.advanceTimersByTime(1000); + spinner.stop(); + + expect(writes).toEqual(['Working...\n']); + }); + + describe('when stderr is a terminal', () => { + beforeEach(() => setTTY(true)); + + it('animates the spinner in place and clears it on stop', () => { + const spinner = createSpinner('Working...'); + spinner.start(); + vi.advanceTimersByTime(160); + spinner.succeed('Done'); + + expect(writes).toHaveLength(5); + expect(writes[0]).toBe('\r\x1b[K⠋ Working...'); + expect(writes.at(-2)).toBe('\r\x1b[K'); + expect(writes.at(-1)).toBe('✓ Done\n'); + }); + + it('redraws the progress line in place and ends it with a newline', () => { + const progress = createProgressLine(); + progress.update('Progress: 0/2 pages (scraping)'); + progress.update('Progress: 2/2 pages (completed)'); + progress.end(); + + expect(writes).toEqual([ + '\r\x1b[KProgress: 0/2 pages (scraping)', + '\r\x1b[KProgress: 2/2 pages (completed)', + '\n', + ]); + }); + }); +}); diff --git a/src/commands/batch-scrape.ts b/src/commands/batch-scrape.ts index 65d2ddde91..e627fb371d 100644 --- a/src/commands/batch-scrape.ts +++ b/src/commands/batch-scrape.ts @@ -10,6 +10,7 @@ import type { import { getClient } from '../utils/client'; import { parseFormats } from '../utils/options'; import { writeOutput } from '../utils/output'; +import { createProgressLine } from '../utils/spinner'; import { normalizeUrl } from '../utils/url'; const TERMINAL_STATUSES = ['completed', 'failed', 'cancelled']; @@ -187,18 +188,19 @@ export async function executeBatchScrape( process.stderr.write(`Job ID: ${response.id}\n`); const startTime = Date.now(); + const progress = createProgressLine(); while (true) { await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000)); const status = await app.getBatchScrapeStatus(response.id, { autoPaginate: false, }); - process.stderr.write( - `\rProgress: ${status.completed}/${status.total} pages (${status.status})` + progress.update( + `Progress: ${status.completed}/${status.total} pages (${status.status})` ); if (TERMINAL_STATUSES.includes(status.status)) { - process.stderr.write('\n'); + progress.end(); return { success: true, data: await app.getBatchScrapeStatus(response.id), @@ -206,7 +208,7 @@ export async function executeBatchScrape( } if (timeout !== undefined && Date.now() - startTime > timeout * 1000) { - process.stderr.write('\n'); + progress.end(); return { success: false, error: `Timeout after ${timeout} seconds. Batch scrape still in progress; check it with: firecrawl batch-scrape ${response.id}`, diff --git a/src/commands/crawl.ts b/src/commands/crawl.ts index 0242956363..6ef47f16e7 100644 --- a/src/commands/crawl.ts +++ b/src/commands/crawl.ts @@ -10,6 +10,7 @@ import type { import { getClient } from '../utils/client'; import { isJobId } from '../utils/job'; import { writeOutput } from '../utils/output'; +import { createProgressLine } from '../utils/spinner'; /** * Execute crawl status check @@ -150,15 +151,15 @@ export async function executeCrawl( const pollMs = waiterOptions.pollInterval * 1000; const startTime = Date.now(); const timeoutMs = timeout !== undefined ? timeout * 1000 : undefined; + const progress = createProgressLine(); while (true) { await new Promise((resolve) => setTimeout(resolve, pollMs)); const status = await app.getCrawlStatus(jobId); - // Show progress - process.stderr.write( - `\rProgress: ${status.completed}/${status.total} pages (${status.status})` + progress.update( + `Progress: ${status.completed}/${status.total} pages (${status.status})` ); if ( @@ -166,7 +167,7 @@ export async function executeCrawl( status.status === 'failed' || status.status === 'cancelled' ) { - process.stderr.write('\n'); + progress.end(); return { success: true, data: status, @@ -175,7 +176,7 @@ export async function executeCrawl( // Check timeout if (timeoutMs && Date.now() - startTime > timeoutMs) { - process.stderr.write('\n'); + progress.end(); return { success: false, error: `Timeout after ${timeout} seconds. Crawl still in progress.`, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index aea91af301..df2258542c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -18,6 +18,15 @@ import { detectAgents, type AgentDetection } from '../utils/agents'; import { compareVersions, getLatestVersion } from '../utils/npm-registry'; import { getAuthSource, type AuthSource } from './status'; import { integrationHeaders } from '../utils/integration'; +import { + bold, + dim, + green, + orange, + red, + reset, + yellow, +} from '../utils/terminal'; type CheckStatus = 'pass' | 'warn' | 'fail'; type DoctorAuthSource = AuthSource | 'flag'; @@ -45,26 +54,6 @@ const DEFAULT_API_URL = 'https://api.firecrawl.dev'; const REACHABILITY_WARN_MS = 2000; const MIN_NODE_MAJOR = 18; -function shouldUseColor(): boolean { - if (process.env.FORCE_COLOR !== undefined) { - return process.env.FORCE_COLOR !== '0'; - } - if (process.env.NO_COLOR !== undefined) return false; - if (process.env.TERM === 'dumb') return false; - return Boolean(process.stdout.isTTY); -} - -const colorEnabled = shouldUseColor(); -const color = (code: string): string => (colorEnabled ? code : ''); - -const orange = color('\x1b[38;5;208m'); -const reset = color('\x1b[0m'); -const dim = color('\x1b[2m'); -const bold = color('\x1b[1m'); -const green = color('\x1b[32m'); -const red = color('\x1b[31m'); -const yellow = color('\x1b[33m'); - function statusIcon(status: CheckStatus): string { switch (status) { case 'pass': diff --git a/src/commands/init.ts b/src/commands/init.ts index a5225e6e64..1565996b25 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -36,6 +36,14 @@ import { type WebAgent, } from '../utils/web-defaults'; import { installMcp } from './setup'; +import { + bold, + dim, + green, + isInteractive, + orange, + reset, +} from '../utils/terminal'; export interface InitOptions { global?: boolean; @@ -50,12 +58,6 @@ export interface InitOptions { template?: string; } -const orange = '\x1b[38;5;208m'; -const reset = '\x1b[0m'; -const dim = '\x1b[2m'; -const bold = '\x1b[1m'; -const green = '\x1b[32m'; - interface TemplateEntry { name: string; description: string; @@ -156,7 +158,7 @@ async function installSkillRepoQuiet( skills, }); - const isTty = process.stdout.isTTY; + const isTty = isInteractive(process.stdout); if (isTty) { process.stdout.write(` ${dim}↓ Installing ${label}...${reset}`); } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 2badcec389..c159f9cad9 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -46,6 +46,7 @@ import { type SkillSelection, } from './skills-install'; import { hasNpx, installSkillsNative } from './skills-native'; +import { dim, green, reset } from '../utils/terminal'; import { configureWebDefaults, WEB_AGENTS, @@ -89,9 +90,6 @@ export interface SetupOptions { skipAuthOffer?: boolean; } -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; const ADD_MCP_PACKAGE = 'add-mcp@1.14.0'; const ENV_API_KEY = 'FIRECRAWL_API_KEY'; const ADD_MCP_LAUNCH_AGENTS: Record = { diff --git a/src/commands/skills-native.ts b/src/commands/skills-native.ts index b572c5ecf4..5fdbd608af 100644 --- a/src/commands/skills-native.ts +++ b/src/commands/skills-native.ts @@ -9,11 +9,7 @@ import path from 'path'; import os from 'os'; import type { SkillFamily } from '../utils/skill-catalog'; import { rememberCatalogNames } from '../utils/skill-update-notice'; - -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const bold = '\x1b[1m'; -const reset = '\x1b[0m'; +import { bold, dim, green, reset } from '../utils/terminal'; const DEFAULT_REPO = 'firecrawl/cli'; const SKILLS_SUBDIR = 'skills'; diff --git a/src/commands/status.ts b/src/commands/status.ts index b9a81cc2a1..de437284bb 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -11,6 +11,7 @@ import { getConfig, validateConfig } from '../utils/config'; import { loadCredentials } from '../utils/credentials'; import { isSearchFeedbackDisabledLocally } from './search-feedback'; import { integrationHeaders } from '../utils/integration'; +import { bold, dim, green, orange, red, reset } from '../utils/terminal'; export type AuthSource = 'env' | 'stored' | 'none'; @@ -246,13 +247,6 @@ async function getLocalStatus(cwd: string): Promise { * Handle status command output */ export async function handleStatusCommand(): Promise { - const orange = '\x1b[38;5;208m'; - const reset = '\x1b[0m'; - const dim = '\x1b[2m'; - const bold = '\x1b[1m'; - const green = '\x1b[32m'; - const red = '\x1b[31m'; - const status = await getStatus(); const localStatus = await getLocalStatus(process.cwd()); diff --git a/src/utils/agent-scaffold/ui.ts b/src/utils/agent-scaffold/ui.ts index 5ef93c8a67..f4fb39c138 100644 --- a/src/utils/agent-scaffold/ui.ts +++ b/src/utils/agent-scaffold/ui.ts @@ -4,14 +4,18 @@ * agent repo's CLI output style so scaffold messages match docs verbatim. */ -export const orange = '\x1b[38;5;208m'; -export const reset = '\x1b[0m'; -export const dim = '\x1b[2m'; -export const bold = '\x1b[1m'; -export const green = '\x1b[32m'; -export const red = '\x1b[31m'; -export const cyan = '\x1b[36m'; -export const yellow = '\x1b[33m'; +import { + bold, + cyan, + dim, + green, + orange, + red, + reset, + yellow, +} from '../terminal'; + +export { bold, cyan, dim, green, orange, red, reset, yellow }; export function printBanner(): void { console.log(''); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 831d3245a9..cccf5693d7 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -11,6 +11,7 @@ import { } from './credentials'; import { updateConfig, getApiKey } from './config'; import { promptInput } from './prompt'; +import { bold, dim, orange, reset } from './terminal'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; // The apex redirects to www, and egress allowlists often permit only www. @@ -700,11 +701,6 @@ function envVarLogin(): { apiKey: string; apiUrl: string } | null { * Print the Firecrawl CLI banner */ function printBanner(): void { - const orange = '\x1b[38;5;208m'; - const reset = '\x1b[0m'; - const dim = '\x1b[2m'; - const bold = '\x1b[1m'; - // Get version from package.json // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require('../../package.json'); @@ -748,9 +744,9 @@ async function interactiveLogin( 'Welcome! To get started, authenticate with your Firecrawl account.\n' ); console.log( - ' \x1b[1m1.\x1b[0m Login with browser \x1b[2m(recommended)\x1b[0m' + ` ${bold}1.${reset} Login with browser ${dim}(recommended)${reset}` ); - console.log(' \x1b[1m2.\x1b[0m Enter API key manually'); + console.log(` ${bold}2.${reset} Enter API key manually`); console.log(''); printEnvHint(); printTelemetryNotice(); @@ -771,8 +767,6 @@ async function interactiveLogin( * Print hint about environment variable */ function printEnvHint(): void { - const dim = '\x1b[2m'; - const reset = '\x1b[0m'; console.log( `${dim}Tip: You can also set FIRECRAWL_API_KEY environment variable${reset}\n` ); @@ -782,9 +776,6 @@ function printEnvHint(): void { * Print telemetry notice */ function printTelemetryNotice(): void { - const dim = '\x1b[2m'; - const reset = '\x1b[0m'; - if (isTelemetryDisabled()) { console.log(`${dim}Telemetry disabled${reset}\n`); } else { diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts index f6c07e7f09..3241613d62 100644 --- a/src/utils/spinner.ts +++ b/src/utils/spinner.ts @@ -1,8 +1,12 @@ /** - * Simple spinner utility for CLI feedback + * Transient progress output on stderr. On a terminal it redraws one line in + * place; anywhere else it prints a plain line each time the message changes. */ +import { isInteractive } from './terminal'; + const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const CLEAR_LINE = '\r\x1b[K'; export interface Spinner { start: (message?: string) => void; @@ -13,24 +17,28 @@ export interface Spinner { } export function createSpinner(initialMessage: string = ''): Spinner { + const interactive = isInteractive(process.stderr); let frameIndex = 0; let interval: ReturnType | null = null; let currentMessage = initialMessage; - - const clearLine = () => { - process.stderr.write('\r\x1b[K'); - }; + let printedMessage: string | undefined; const render = () => { const frame = SPINNER_FRAMES[frameIndex]; - clearLine(); - process.stderr.write(`${frame} ${currentMessage}`); + process.stderr.write(`${CLEAR_LINE}${frame} ${currentMessage}`); frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; }; + const printIfChanged = () => { + if (!currentMessage || currentMessage === printedMessage) return; + process.stderr.write(`${currentMessage}\n`); + printedMessage = currentMessage; + }; + return { start(message?: string) { if (message) currentMessage = message; + if (!interactive) return printIfChanged(); if (interval) return; render(); interval = setInterval(render, 80); @@ -38,6 +46,7 @@ export function createSpinner(initialMessage: string = ''): Spinner { update(message: string) { currentMessage = message; + if (!interactive) printIfChanged(); }, stop(finalMessage?: string) { @@ -45,7 +54,7 @@ export function createSpinner(initialMessage: string = ''): Spinner { clearInterval(interval); interval = null; } - clearLine(); + if (interactive) process.stderr.write(CLEAR_LINE); if (finalMessage) { process.stderr.write(`${finalMessage}\n`); } @@ -60,3 +69,25 @@ export function createSpinner(initialMessage: string = ''): Spinner { }, }; } + +export interface ProgressLine { + update: (text: string) => void; + end: () => void; +} + +export function createProgressLine(): ProgressLine { + const interactive = isInteractive(process.stderr); + let lastText: string | undefined; + + return { + update(text: string) { + if (text === lastText) return; + process.stderr.write(interactive ? `${CLEAR_LINE}${text}` : `${text}\n`); + lastText = text; + }, + + end() { + if (interactive && lastText !== undefined) process.stderr.write('\n'); + }, + }; +} diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts new file mode 100644 index 0000000000..30102ce7ed --- /dev/null +++ b/src/utils/terminal.ts @@ -0,0 +1,29 @@ +type Stream = Pick; + +/** True when the stream is a terminal that can redraw a line in place. */ +export function isInteractive(stream: Stream): boolean { + return Boolean(stream.isTTY) && process.env.TERM !== 'dumb'; +} + +/** NO_COLOR wins over FORCE_COLOR; otherwise only interactive streams get color. */ +export function supportsColor(stream: Stream): boolean { + if (process.env.NO_COLOR) return false; + if (process.env.FORCE_COLOR !== undefined) { + return process.env.FORCE_COLOR !== '0'; + } + return isInteractive(stream); +} + +// Shared by stdout and stderr writes, so color needs both to support it. +const colorEnabled = + supportsColor(process.stdout) && supportsColor(process.stderr); +const sgr = (code: string): string => (colorEnabled ? `\x1b[${code}m` : ''); + +export const reset = sgr('0'); +export const bold = sgr('1'); +export const dim = sgr('2'); +export const red = sgr('31'); +export const green = sgr('32'); +export const yellow = sgr('33'); +export const cyan = sgr('36'); +export const orange = sgr('38;5;208'); diff --git a/src/utils/update-notice-format.ts b/src/utils/update-notice-format.ts index fb4ee872ac..4333eafb68 100644 --- a/src/utils/update-notice-format.ts +++ b/src/utils/update-notice-format.ts @@ -1,4 +1,5 @@ import { stripVTControlCharacters } from 'node:util'; +import { supportsColor } from './terminal'; const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); const wide = @@ -31,12 +32,7 @@ export function colorizeUpdateNotice( stderr: Pick, highlights: { title: string; commands: readonly string[]; url: string } ): string { - if (process.env.NO_COLOR !== undefined) return message; - const supportsColor = - process.env.FORCE_COLOR !== undefined - ? process.env.FORCE_COLOR !== '0' - : Boolean(stderr.isTTY); - if (!supportsColor) return message; + if (!supportsColor(stderr)) return message; let result = message.replace( highlights.title, `\x1b[1;36m${highlights.title}\x1b[0m`