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:
Colby Mchenry
2026-07-16 14:39:26 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 5736e24bb6
commit d6efd437b3
7 changed files with 243 additions and 24 deletions
+38
View File
@@ -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;
}
+32 -1
View File
@@ -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();
},
};
}
+15 -4
View File
@@ -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);