Skip to content

Commit fdac149

Browse files
authored
feat(cli): show index build progress (#159)
* feat(cli): show index build progress * fix(cli): refine index progress lifecycle
1 parent 71fd100 commit fdac149

21 files changed

Lines changed: 599 additions & 84 deletions

codegraph-skill/codegraph/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Use `--root` to define the boundary for config lookup, cache scope, path confine
3636
- `codegraph.config.json` discovery globs are project-root-relative.
3737
- CLI `--include-glob` and `--ignore-glob` values are one-off filters relative to each active scan root.
3838
- Use `--no-gitignore` only when ignored files are intentionally in scope.
39+
- Index builds and updates report progress automatically on interactive stderr while reusable cache hits stay quiet. Use `--progress` for redirected progress logs or `--no-progress` to suppress feedback; JSON stdout is unchanged.
3940

4041
## Choose the Smallest Follow-up
4142

docs/cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,11 @@ Short JSON shape:
307307
308308
- Use `explore --pretty` for a one-call repo question that combines search anchors, bounded packets, dependency paths, reverse dependencies, candidate tests, limits, omissions, and follow-ups. Use `--limit`, `--max-packets`, `--max-paths`, or `--no-source` to keep output small.
309309
- Use `orient --pretty` as the compact first-turn reading surface for people or models; it prints the ranked `focus` targets and their follow-up commands before the scope sketch.
310-
- Use `orient --json` when follow-up tools need exact focus reasons, limits, and omitted counts. Orient suppresses index rebuild warnings so stdout stays parseable.
310+
- Use `orient --json` when follow-up tools need exact focus reasons, limits, and omitted counts. Index feedback is stderr-only, so stdout remains parseable.
311311
- Small orientation budgets default to `--health skip`. Medium and large default to `--health summary`, which counts cycles and unresolved imports while omitting duplicate health; use `--health full` when exhaustive duplicate counts matter.
312312
- Use `packet get` with file paths, symbol names, SQL object names, file/symbol/chunk/SQL/graph handles, or review handles to retrieve bounded evidence plus follow-up commands.
313313
- Agent commands reuse the incremental index path and default to disk cache. Use shared index flags such as `--cache`, `--cache-strict`, `--cache-verify`, `--threads`, `--native`, `--workers`, `--include-glob`, `--ignore-glob`, and `--no-gitignore` when the packet should match a specific scan mode.
314+
- When an index must be built or updated, interactive terminals immediately show progress on stderr; cache and snapshot hits stay quiet. Use `--progress` to force newline-delimited updates when stderr is redirected, or `--no-progress` to suppress feedback.
314315
315316
#### Live file views
316317

docs/library-api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,8 @@ const incremental = await buildProjectIndexIncremental(root, {
553553

554554
`changedSince` follows `git diff <rev>` semantics, while `gitBase` and `gitHead` use an explicit `<base>..<head>` range for normal revisions. `gitHead` also accepts `WORKTREE` for staged and unstaged tracked-file changes, or `STAGED`/`INDEX` for the current index.
555555

556+
`BuildOptions.onProgress` reports index lifecycle and file progress. A rebuild emits `phase: "start"` with `mode: "build"` or `"update"`, zero or more `phase: "update"` events, and `phase: "complete"` with `elapsedMs`; a reusable snapshot emits no progress events.
557+
556558
## Project file discovery and graph building
557559

558560
`listProjectFiles` defaults to source files plus common project manifests and lockfiles across supported languages, for example `package.json`, `requirements.txt`, `pyproject.toml`, and `Cargo.toml`.

src/cli.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { handleOrientCommand } from "./cli/orient.js";
4747
import { handleDumpmodCommand, handleGotoCommand, handleRefsCommand } from "./cli/navigation.js";
4848
import { parseCacheModeOption, parseOptionalNonNegativeIntegerOption, validateCliArgs } from "./cli/options.js";
4949
import { getCodegraphPackageIdentity, getCodegraphVersion } from "./cli/packageInfo.js";
50+
import type { CliProgressPolicy } from "./cli/progress.js";
5051
import { handlePacketCommand } from "./cli/packet.js";
5152
import { handleReviewCommand } from "./cli/review.js";
5253
import { handleSearchCommand } from "./cli/search.js";
@@ -170,8 +171,14 @@ async function runCliWithActiveRuntime(rawArgs: string[]) {
170171
const nativeMode = parseNativeRuntimeMode(getOpt("--native"));
171172
const useNativeWorkers = hasFlag("--workers");
172173
const workerOpts = useNativeWorkers ? ({ useNativeWorkers: true } as const) : ({} as const);
173-
const showProgress = hasFlag("--progress");
174-
const progressHandler = createCliProgressHandler(showProgress);
174+
let progressPolicy: CliProgressPolicy = "auto";
175+
if (hasFlag("--no-progress")) {
176+
progressPolicy = "never";
177+
} else if (hasFlag("--progress")) {
178+
progressPolicy = "always";
179+
}
180+
const showBuildDiagnostics = hasFlag("--progress");
181+
const progressHandler = createCliProgressHandler(progressPolicy);
175182
const graphFlags = {
176183
fast: hasFlag("--fast-graph"),
177184
resolveNodeModules: hasFlag("--resolve-node-modules"),
@@ -802,6 +809,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) {
802809
gitBase,
803810
gitHead,
804811
changedSince,
812+
progressHandler,
805813
writeJSONLine,
806814
});
807815
return;
@@ -826,7 +834,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) {
826834
changedSince,
827835
reportEnabled,
828836
reportFile,
829-
showProgress,
837+
showProgress: showBuildDiagnostics,
830838
getOpt,
831839
hasFlag,
832840
cwd: getCwd,
@@ -860,7 +868,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) {
860868
writeStderrLine,
861869
writeCommandReport,
862870
maybeWriteNativeBackendStatus,
863-
showProgress,
871+
showProgress: showBuildDiagnostics,
864872
});
865873
return;
866874
}
@@ -1018,6 +1026,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) {
10181026
nativeMode,
10191027
useNativeWorkers,
10201028
graphOptions: hasGraphOverrides ? buildGraphOptions() : undefined,
1029+
progressHandler,
10211030
writeJSONLine,
10221031
writeStdoutLine,
10231032
writeStderrLine,

src/cli/context.ts

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { normalizePath, resolveFilePathFromRoot } from "../util/paths.js";
99
import { matchesDiscoveryGlob, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js";
1010
import { isRelativePathInside } from "../util/projectFiles.js";
1111
import { isCliValueOption, type ParsedCliArgs } from "./options.js";
12+
import {
13+
createCliProgressDisplay,
14+
resolveCliProgressPresentation,
15+
type CliProgressDisplay,
16+
type CliProgressPolicy,
17+
} from "./progress.js";
1218

1319
function toJSON(obj: unknown): string {
1420
return JSON.stringify(obj, null, 2);
@@ -116,6 +122,8 @@ export type CliRuntime = {
116122
exit: (code: number) => never;
117123
cwd: () => string;
118124
readStdin: () => Promise<string>;
125+
stderrIsTTY: () => boolean;
126+
terminalSupportsControlSequences: () => boolean;
119127
};
120128

121129
export type CliPositionalsContext = {
@@ -164,6 +172,7 @@ export type CliAgentCommandContext = {
164172
type CliContext = {
165173
runtime: CliRuntime;
166174
stderrFilePath: string | undefined;
175+
progressDisplay: CliProgressDisplay | undefined;
167176
};
168177

169178
function createDefaultCliRuntime(): CliRuntime {
@@ -172,6 +181,8 @@ function createDefaultCliRuntime(): CliRuntime {
172181
stderr: (chunk) => process.stderr.write(chunk),
173182
exit: (code) => process.exit(code),
174183
cwd: () => process.cwd(),
184+
stderrIsTTY: () => !!process.stderr.isTTY,
185+
terminalSupportsControlSequences: () => !!process.stderr.isTTY && process.env.TERM !== "dumb",
175186
readStdin: async () =>
176187
await new Promise<string>((resolve, reject) => {
177188
let data = "";
@@ -206,13 +217,15 @@ function createDefaultCliRuntime(): CliRuntime {
206217
const defaultCliContext: CliContext = {
207218
runtime: createDefaultCliRuntime(),
208219
stderrFilePath: undefined,
220+
progressDisplay: undefined,
209221
};
210222
const cliContextStorage = new AsyncLocalStorage<CliContext>();
211223

212224
function createCliContext(runtime: Partial<CliRuntime> = {}): CliContext {
213225
return {
214226
runtime: { ...createDefaultCliRuntime(), ...runtime },
215227
stderrFilePath: undefined,
228+
progressDisplay: undefined,
216229
};
217230
}
218231

@@ -222,7 +235,13 @@ function getCliContext(): CliContext {
222235

223236
export async function runWithCliRuntime<T>(runtime: Partial<CliRuntime>, callback: () => Promise<T>): Promise<T> {
224237
const context = createCliContext(runtime);
225-
return await cliContextStorage.run(context, callback);
238+
return await cliContextStorage.run(context, async () => {
239+
try {
240+
return await callback();
241+
} finally {
242+
context.progressDisplay?.dispose();
243+
}
244+
});
226245
}
227246

228247
export function getCwd(): string {
@@ -237,16 +256,14 @@ export function exitCli(code: number): never {
237256
return getCliContext().runtime.exit(code);
238257
}
239258

240-
function writeStderrChunk(message: string): void {
241-
getCliContext().runtime.stderr(message);
242-
}
243-
244259
export function setCliStderrFilePath(filePath: string | undefined): void {
245260
getCliContext().stderrFilePath = filePath;
246261
}
247262

248263
export function writeStdoutLine(message: string): void {
249-
getCliContext().runtime.stdout(`${message}\n`);
264+
const context = getCliContext();
265+
context.progressDisplay?.clear();
266+
context.runtime.stdout(`${message}\n`);
250267
}
251268

252269
export function writeJSONLine(value: unknown): void {
@@ -255,6 +272,7 @@ export function writeJSONLine(value: unknown): void {
255272

256273
export function writeStderrLine(message: string): void {
257274
const context = getCliContext();
275+
context.progressDisplay?.clear();
258276
context.runtime.stderr(`${message}\n`);
259277
try {
260278
if (context.stderrFilePath) {
@@ -334,28 +352,22 @@ export function maybeWriteNativeBackendStatus(report: BuildReport | undefined, s
334352
if (parserSummary) writeStderrLine(parserSummary);
335353
}
336354

337-
export function createCliProgressHandler(
338-
showProgress: boolean,
339-
): ((update: { current: number; total: number }) => void) | undefined {
340-
if (!showProgress) return undefined;
341-
let lastProgressUpdate = 0;
342-
return (update) => {
343-
const now = Date.now();
344-
const isComplete = update.current === update.total;
345-
const shouldUpdate = isComplete || now - lastProgressUpdate > 100;
346-
347-
if (shouldUpdate) {
348-
if (process.stderr.isTTY) {
349-
writeStderrChunk(`\r[Progress] ${update.current}/${update.total} files processed...`);
350-
if (isComplete) {
351-
writeStderrChunk("\n");
352-
}
353-
} else if (update.current === 1 || isComplete || update.current % 100 === 0) {
354-
writeStderrChunk(`[Progress] ${update.current}/${update.total} files processed.\n`);
355-
}
356-
lastProgressUpdate = now;
357-
}
358-
};
355+
export function createCliProgressHandler(policy: CliProgressPolicy): BuildOptions["onProgress"] {
356+
const context = getCliContext();
357+
const presentation = resolveCliProgressPresentation({
358+
policy,
359+
stderrIsTTY: context.runtime.stderrIsTTY(),
360+
terminalSupportsControlSequences: context.runtime.terminalSupportsControlSequences(),
361+
});
362+
if (presentation === "off") return undefined;
363+
364+
context.progressDisplay?.dispose();
365+
const display = createCliProgressDisplay({
366+
presentation,
367+
write: context.runtime.stderr,
368+
});
369+
context.progressDisplay = display;
370+
return display.update;
359371
}
360372

361373
type CommandTimingReport = {

src/cli/graph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
graphToMermaidSymbolsWithFiles,
1414
} from "../graphs/symbol-render.js";
1515
import { buildProjectIndexFromFiles, buildProjectIndexIncremental } from "../indexer/build-index.js";
16-
import { type BuildReport } from "../indexer/types.js";
16+
import { type BuildOptions, type BuildReport } from "../indexer/types.js";
1717
import type { NativeRuntimeMode } from "../native/treeSitterNative.js";
1818
import { updateGraphSqlite, writeGraphSqlite } from "../sqlite.js";
1919
import { buildSqlArtifactGraphFromFiles } from "../sql/index.js";
@@ -65,7 +65,7 @@ export type GraphCommandContext = {
6565
discoveryOptions: ProjectFileDiscoveryOptions;
6666
nativeMode: NativeRuntimeMode;
6767
workerOpts: { useNativeWorkers: true } | Record<string, never>;
68-
progressHandler: ((update: { current: number; total: number }) => void) | undefined;
68+
progressHandler: BuildOptions["onProgress"];
6969
graphFlags: {
7070
fast: boolean;
7171
resolveNodeModules: boolean;

src/cli/graphDelta.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type GraphDeltaCommandContext = {
1818
gitBase: string | undefined;
1919
gitHead: string | undefined;
2020
changedSince: string | undefined;
21+
progressHandler: IncrementalBuildOptions["onProgress"];
2122
writeJSONLine: (value: unknown) => void;
2223
};
2324

@@ -35,6 +36,7 @@ export async function handleGraphDeltaCommand(context: GraphDeltaCommandContext)
3536
cacheVerify,
3637
incrementalStrict,
3738
files: context.files,
39+
...(context.progressHandler ? { onProgress: context.progressHandler } : {}),
3840
};
3941
if (context.nativeMode !== "auto") deltaOptions.native = context.nativeMode;
4042
if (cache !== undefined) deltaOptions.cache = cache;

0 commit comments

Comments
 (0)