fix(cli): honor NO_COLOR/--no-color and go plain when stdout is piped (#1306)
List commands (status, query, callers, callees, impact, files) embedded ANSI color codes even when stdout was a pipe, and NO_COLOR had no effect. One switch now decides color for all codegraph-authored output: --no-color > --color > NO_COLOR > FORCE_COLOR > stdout TTY > CI. Piped init/index/sync also stop emitting shimmer animation frames (\r + erase-line rewrites) and print one plain line per phase instead; a TTY with NO_COLOR keeps the animation but drops the color codes. The detection mirrors picocolors' so @clack frames and our own lines agree within a run. Fixes #1281 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5736e24bb6
commit
d6efd437b3
+43
-14
@@ -45,6 +45,7 @@ import { extractProseCandidates } from '../search/identifier-segments';
|
||||
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
|
||||
import { createShimmerProgress } from '../ui/shimmer-progress';
|
||||
import { getGlyphs } from '../ui/glyphs';
|
||||
import { ansiColorsEnabled } from '../ui/color';
|
||||
|
||||
import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
|
||||
import { installFatalHandlers } from './fatal-handler';
|
||||
@@ -53,13 +54,18 @@ import { installCommandSupervision } from './command-supervision';
|
||||
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
|
||||
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
|
||||
|
||||
// Decided once, before `--color`/`--no-color` are stripped from argv below
|
||||
// (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output.
|
||||
const COLORS_ENABLED = ansiColorsEnabled();
|
||||
|
||||
// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
|
||||
async function loadCodeGraph(): Promise<typeof import('../index')> {
|
||||
try {
|
||||
return await import('../index');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`\x1b[31m${getGlyphs().err}\x1b[0m Failed to load CodeGraph modules.`);
|
||||
const [red, reset] = COLORS_ENABLED ? ['\x1b[31m', '\x1b[0m'] : ['', ''];
|
||||
console.error(`${red}${getGlyphs().err}${reset} Failed to load CodeGraph modules.`);
|
||||
console.error(`\n Node: ${process.version} Platform: ${process.platform} ${process.arch}`);
|
||||
console.error(`\n Error: ${msg}`);
|
||||
console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n');
|
||||
@@ -152,18 +158,36 @@ if (firstArg === '-v' || firstArg === '-version') {
|
||||
// ANSI Color Helpers (avoid chalk ESM issues)
|
||||
// =============================================================================
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
gray: '\x1b[90m',
|
||||
};
|
||||
// `--color` / `--no-color` are global and position-independent — they were
|
||||
// already read by ansiColorsEnabled() at module load, so strip them before
|
||||
// commander parses (a subcommand would otherwise reject the unknown flag).
|
||||
process.argv = process.argv.filter((a) => a !== '--color' && a !== '--no-color');
|
||||
|
||||
const colors = COLORS_ENABLED
|
||||
? {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
gray: '\x1b[90m',
|
||||
}
|
||||
: {
|
||||
reset: '',
|
||||
bold: '',
|
||||
dim: '',
|
||||
red: '',
|
||||
green: '',
|
||||
yellow: '',
|
||||
blue: '',
|
||||
cyan: '',
|
||||
white: '',
|
||||
gray: '',
|
||||
};
|
||||
|
||||
const chalk = {
|
||||
bold: (s: string) => `${colors.bold}${s}${colors.reset}`,
|
||||
@@ -180,7 +204,12 @@ const chalk = {
|
||||
program
|
||||
.name('codegraph')
|
||||
.description('Code intelligence and knowledge graph for any codebase')
|
||||
.version(packageJson.version);
|
||||
.version(packageJson.version)
|
||||
// Parsed manually before commander runs (any argv position works); declared
|
||||
// here so they show up in --help. NO_COLOR / FORCE_COLOR env vars are also
|
||||
// honored, and piped output defaults to no color (#1281).
|
||||
.option('--color', 'force ANSI colors even when stdout is not a TTY')
|
||||
.option('--no-color', 'disable ANSI colors (NO_COLOR env is also honored)');
|
||||
|
||||
// Anonymous usage telemetry (see TELEMETRY.md): record the invoked subcommand
|
||||
// NAME only — never arguments or paths. Counts buffer locally; network sends
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Terminal color detection for CLI output (issue #1281).
|
||||
*
|
||||
* One switch decides whether any codegraph-authored output carries ANSI
|
||||
* color codes. Precedence, strongest first:
|
||||
*
|
||||
* 1. `--no-color` anywhere on the command line -> off
|
||||
* 2. `--color` anywhere on the command line -> on
|
||||
* 3. `NO_COLOR` set and non-empty (no-color.org) -> off
|
||||
* 4. `FORCE_COLOR` set and non-empty -> on ('0'/'false' -> off)
|
||||
* 5. stdout is a TTY and TERM != 'dumb' -> on
|
||||
* 6. `CI` set and non-empty -> on (CI log viewers render ANSI)
|
||||
* 7. otherwise (piped/redirected stdout) -> off
|
||||
*
|
||||
* This intentionally tracks the detection @clack/prompts inherits from
|
||||
* picocolors closely enough that one run never mixes colored clack frames
|
||||
* with uncolored codegraph lines (or vice versa) for the common cases:
|
||||
* both honor NO_COLOR, --no-color/--color, FORCE_COLOR, TTY, and CI.
|
||||
*/
|
||||
export function ansiColorsEnabled(): boolean {
|
||||
if (process.argv.includes('--no-color')) return false;
|
||||
if (process.argv.includes('--color')) return true;
|
||||
|
||||
const noColor = process.env.NO_COLOR;
|
||||
if (noColor !== undefined && noColor !== '') return false;
|
||||
|
||||
const forceColor = process.env.FORCE_COLOR;
|
||||
if (forceColor !== undefined && forceColor !== '') {
|
||||
return forceColor !== '0' && forceColor.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
if (process.stdout.isTTY === true && process.env.TERM !== 'dumb') return true;
|
||||
|
||||
const ci = process.env.CI;
|
||||
if (ci !== undefined && ci !== '') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Worker } from 'worker_threads';
|
||||
import * as path from 'path';
|
||||
import { ansiColorsEnabled } from './color';
|
||||
|
||||
const PHASE_NAMES: Record<string, string> = {
|
||||
scanning: 'Scanning files',
|
||||
@@ -21,11 +22,19 @@ export interface ShimmerProgress {
|
||||
}
|
||||
|
||||
export function createShimmerProgress(): ShimmerProgress {
|
||||
// Piped/redirected stdout: `\r`-rewriting animation frames are garbage in a
|
||||
// log file — emit one plain line per phase instead (#1281).
|
||||
if (process.stdout.isTTY !== true) {
|
||||
return createPlainProgress();
|
||||
}
|
||||
|
||||
let lastPhase = '';
|
||||
|
||||
const workerPath = path.join(__dirname, 'shimmer-worker.js');
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: { startTime: Date.now() },
|
||||
// colors:false keeps the animation (still an interactive TTY) but drops
|
||||
// the ANSI color codes, honoring NO_COLOR / --no-color (#1281).
|
||||
workerData: { startTime: Date.now(), colors: ansiColorsEnabled() },
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -72,3 +81,25 @@ export function createShimmerProgress(): ShimmerProgress {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-TTY fallback: one plain line per phase, no rewrites, no ANSI.
|
||||
* Completion details (counts, timings) are printed by the caller's result
|
||||
* summary, so phase starts are all that's worth logging here.
|
||||
*/
|
||||
function createPlainProgress(): ShimmerProgress {
|
||||
let lastPhase = '';
|
||||
|
||||
return {
|
||||
onProgress(progress: IndexProgress) {
|
||||
if (progress.phase === lastPhase) return;
|
||||
lastPhase = progress.phase;
|
||||
const phaseName = PHASE_NAMES[progress.phase] || progress.phase;
|
||||
process.stdout.write(`${phaseName}...\n`);
|
||||
},
|
||||
|
||||
stop() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,10 +21,16 @@ const SPINNER_GLYPHS = G.spinner;
|
||||
const ANIM_INTERVAL = 150;
|
||||
const FRAMES_PER_GLYPH = 3;
|
||||
|
||||
const RST = '\x1b[0m';
|
||||
const DM = '\x1b[2m';
|
||||
const GRN = '\x1b[32m';
|
||||
const BOLD = '\x1b[1m';
|
||||
// colors:false (NO_COLOR / --no-color on an interactive TTY, #1281) keeps the
|
||||
// animation but drops every color/style code. `\r\x1b[K` line rewrites stay —
|
||||
// they're cursor control, not color, and the parent only spawns this worker
|
||||
// when stdout is a real TTY.
|
||||
const COLORS: boolean = workerData.colors !== false;
|
||||
|
||||
const RST = COLORS ? '\x1b[0m' : '';
|
||||
const DM = COLORS ? '\x1b[2m' : '';
|
||||
const GRN = COLORS ? '\x1b[32m' : '';
|
||||
const BOLD = COLORS ? '\x1b[1m' : '';
|
||||
|
||||
const startTime: number = workerData.startTime;
|
||||
|
||||
@@ -37,6 +43,7 @@ function lerp(a: number, b: number, t: number): number {
|
||||
}
|
||||
|
||||
function shimmerColor(frame: number): string {
|
||||
if (!COLORS) return '';
|
||||
const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2;
|
||||
const r = lerp(160, 251, t);
|
||||
const g = lerp(100, 191, t);
|
||||
@@ -55,6 +62,10 @@ function renderBar(frame: number, filled: number, empty: number): string {
|
||||
const shimmerWidth = 3;
|
||||
let bar = '';
|
||||
for (let i = 0; i < filled; i++) {
|
||||
if (!COLORS) {
|
||||
bar += G.barFilled;
|
||||
continue;
|
||||
}
|
||||
const dist = Math.abs(i - shimmerPos);
|
||||
const t = Math.max(0, 1 - dist / shimmerWidth);
|
||||
const r = lerp(160, 251, t);
|
||||
|
||||
@@ -28,6 +28,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { ansiColorsEnabled } from '../ui/color';
|
||||
|
||||
export const REPO = 'colbymchenry/codegraph';
|
||||
export const NPM_PACKAGE = '@colbymchenry/codegraph';
|
||||
@@ -305,12 +306,14 @@ export interface UpgradeDeps {
|
||||
offerBetaSignup?: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Colors off when piped / NO_COLOR / --no-color (#1281).
|
||||
const useColor = ansiColorsEnabled();
|
||||
const c = {
|
||||
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
||||
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
||||
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
||||
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
||||
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
||||
bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
||||
dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
|
||||
green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
|
||||
yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
|
||||
cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
|
||||
};
|
||||
|
||||
/** The honest, additive re-index reminder shown after a successful upgrade. */
|
||||
|
||||
Reference in New Issue
Block a user