Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
59513d9
feat(cli): add git-style pager to diff and log commands
davidfirst Jul 7, 2026
6c64e4b
fix(cli): don't page diff/log output that already fits on one screen
davidfirst Jul 7, 2026
c0ad1f7
refactor(cli): reuse removeChalkCharacters in pager width measurement
davidfirst Jul 7, 2026
785d8e5
fix(cli): harden pager fallback and extend paging to lane diff
davidfirst Jul 7, 2026
163776d
Merge branch 'master' into cli-pager-diff-log
davidfirst Jul 8, 2026
6a333aa
fix(cli): respect empty BIT_PAGER and cap fitsOnScreen line split
davidfirst Jul 8, 2026
c52c23b
fix(cli): let --pager override BIT_NO_PAGER env var
davidfirst Jul 8, 2026
f510bc7
fix(cli): make --pager override BIT_PAGER=cat/empty disable
davidfirst Jul 8, 2026
dbc3b21
fix(cli): detect ai-agent env vars by presence, not truthiness
davidfirst Jul 8, 2026
0b7dcb8
Merge branch 'master' into cli-pager-diff-log
davidfirst Jul 8, 2026
4e42146
fix(cli): treat CI and BIT_NO_PAGER as presence-based opt-outs
davidfirst Jul 8, 2026
887a4f7
fix(cli): only set LESS=FRX when the pager is actually less
davidfirst Jul 8, 2026
e3c7a75
Merge branch 'master' into cli-pager-diff-log
davidfirst Jul 8, 2026
0430dd1
docs(cli): clarify why daemon contexts skip paging
davidfirst Jul 8, 2026
9cfc75a
docs(cli): fix writeToPager resolution semantics in jsdoc
davidfirst Jul 8, 2026
63b5835
fix(isolator): suppress spurious capsule-build TS2345 on PackageJsonFile
davidfirst Jul 8, 2026
5343a41
Merge branch 'master' into cli-pager-diff-log
davidfirst Jul 9, 2026
af81e65
chore: re-trigger CI to confirm flaky capsule integration tests
davidfirst Jul 10, 2026
ad3fd84
Merge branch 'master' into cli-pager-diff-log
davidfirst Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scopes/component/component-compare/diff-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ if both "version" and "to-version" are provided, compare those two versions dire
{ cmd: 'diff foo --json', description: 'return the diff result as json for programmatic consumption' },
];
loader = true;
pager = true;

constructor(private componentCompareMain: ComponentCompareMain) {}

Expand Down
1 change: 1 addition & 0 deletions scopes/component/component-log/log-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use various format options for compact or detailed views of version history.`;
] as CommandOptions;
remoteOp = true; // should support log against remote
skipWorkspace = true;
pager = true;
arguments = [{ name: 'id', description: 'component-id or component-name' }];

constructor(private componentLog: ComponentLogMain) {}
Expand Down
6 changes: 6 additions & 0 deletions scopes/harmony/cli/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { loader } from '@teambit/legacy.loader';
import { handleErrorAndExit } from './handle-errors';
import { TOKEN_FLAG_NAME, CACHE_ROOT } from '@teambit/legacy.constants';
import globalFlags from './global-flags';
import { shouldUsePager, writeToPager } from './pager';
import { Analytics } from '@teambit/legacy.analytics';
import type { OnCommandStartSlot } from './cli.main.runtime';
import pMapSeries from 'p-map-series';
Expand Down Expand Up @@ -142,6 +143,11 @@ export class CommandRunner {
}

private async writeAndExit(data: string, exitCode: number) {
if (shouldUsePager(this.command, this.flags)) {
const paged = await writeToPager(data);
// if the pager couldn't launch, fall through to a direct write so output is never lost.
if (paged) return logger.exitAfterFlush(exitCode, this.commandName, data);
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
return process.stdout.write(data, async () => logger.exitAfterFlush(exitCode, this.commandName, data));
}
Expand Down
9 changes: 9 additions & 0 deletions scopes/harmony/cli/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export interface Command {
*/
loader?: boolean;

/**
* opt this command's `report` output into paging (like git's log/diff).
* when true, and stdout is an interactive human terminal, the output is piped through a pager
* (`less` by default). non-interactive contexts (piped output, CI, ai-agents, bit-cli-server)
* always get the full output at once. users can override with --pager / --no-pager.
* the default is false.
*/
pager?: boolean;

/**
* Array of command options where each element is a tuple.
* ['flag alias', 'flag name', 'flag description']
Expand Down
72 changes: 72 additions & 0 deletions scopes/harmony/cli/pager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { spawn } from 'child_process';
import { logger } from '@teambit/legacy.logger';
import type { Command, Flags } from './command';

/**
* best-effort list of env vars set by ai-agent / automation runners. when any is present we
* never page, so agents always receive the full command output at once (never partial/paged
* data), even in the rare case they allocate a pseudo-tty.
*/
const AI_AGENT_ENV_VARS = ['CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT'];

/**
* detect whether the output is going to an interactive human terminal.
* anything non-interactive (piped output, CI, ai-agents, bit-cli-server) gets the full output
* with no pager.
*/
export function isInteractiveTerminal(): boolean {
if (!process.stdout.isTTY) return false;
if (logger.isDaemon) return false; // bit-cli-server: output travels over IPC, not a terminal
if (process.env.CI) return false;
if (AI_AGENT_ENV_VARS.some((name) => process.env[name])) return false;
return true;
Comment thread
davidfirst marked this conversation as resolved.
}

/**
* decide whether a command's report output should be piped through a pager.
* mirrors git: on by default for interactive terminals, off for anything else. explicit flags
* (--pager / --no-pager) and the BIT_NO_PAGER env var override the automatic behavior.
*/
export function shouldUsePager(command: Command, flags: Flags): boolean {
if (!command.pager) return false; // command didn't opt-in to paging
if (flags.json) return false; // json is for machine consumption, never page it
if (flags['no-pager'] || process.env.BIT_NO_PAGER) return false;
if (flags.pager) return true; // explicit force-on, even when non-interactive
return isInteractiveTerminal();
}

/**
* pipe the given output through a pager (`less` by default). resolves to `true` once the data
* was handed off to the pager, or `false` when no pager could be launched - in which case the
* caller must write the data directly so nothing is lost.
*
* the pager binary is taken from BIT_PAGER, then PAGER, defaulting to `less`. for `less` we set
* `LESS=FRX` (unless already set): quit if the output fits one screen (-F), keep ansi colors
* (-R), and don't clear the screen on exit (-X). this means short output behaves exactly as it
* did before (printed and back to the prompt, no full-screen pager).
*/
export function writeToPager(data: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const configuredPager = process.env.BIT_PAGER || process.env.PAGER;
const [cmd, ...args] = (configuredPager || 'less').trim().split(/\s+/);
if (!cmd || cmd === 'cat') {
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
resolve(false); // paging effectively disabled by the user (empty / "cat" pager)
return;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
const env = { ...process.env };
if (!env.LESS) env.LESS = 'FRX';

Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
try {
const child = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'], env });
// pager missing (ENOENT) or otherwise failed to launch => fall back to a direct write.
child.on('error', () => resolve(false));
child.on('close', () => resolve(true));
// ignore EPIPE that happens when the user quits the pager (e.g. "q") before all data is read.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
child.stdin?.on('error', () => {});
child.stdin?.write(data);
child.stdin?.end();
Comment thread
davidfirst marked this conversation as resolved.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
} catch {
resolve(false);
}
});
}
12 changes: 12 additions & 0 deletions scopes/harmony/cli/yargs-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ export class YargsAdapter implements CommandModule {
'useful when it fails to load normally. it skips loading aspects from workspace.jsonc, and for legacy-commands it initializes only the CLI aspect',
group: GLOBAL_GROUP,
};
if (command.pager) {
globalOptions.pager = {
describe: 'pipe the output through a pager (e.g. less). auto-enabled for interactive terminals',
group: GLOBAL_GROUP,
type: 'boolean',
};
globalOptions['no-pager'] = {
describe: 'print all output at once without a pager (default for ai-agents, CI, and piped output)',
group: GLOBAL_GROUP,
type: 'boolean',
};
}
return globalOptions;
}
}