Warning
Pre-1.0.0, breaking changes may happen in any minor release. SemVer guarantees will begin at 1.0.0.
I recommend using only the Progress.all and Progress.forEach APIs for now, as they will likely change the least. The lower-level APIs for manual progress bar control are more likely to see breaking changes as I iterate on the design.
Please open an issue or reach out if you have any questions or want to contribute! Feedback and contributions are very welcome!
effective-progress is an Effect-native CLI progress bar library with:
- multiple nested tree-like progress bars
- spinner support for “we have no idea how long this takes” work
- keep using
Console.log/Effect.logInfowhile progress rendering is active - familiar
.alland.forEachAPIs — swapEffectforProgress, get progress bars basically for free - flicker-free rendering with Ink
bun add effective-progressIterate items with a single progress bar.
import { Console, Effect } from "effect";
import * as Progress from "effective-progress";
const program = Progress.all(
Array.from({ length: 5 }).map((_, i) =>
Effect.gen(function* () {
yield* Effect.sleep("1 second");
yield* Console.log(`Completed task ${i + 1}`);
}),
),
{ description: "Running tasks in parallel", concurrency: 2 },
);
Effect.runPromise(program);Nested progress bars with tree-style rendering that highlights parent tasks and their subtasks
import { Effect } from "effect";
import * as Progress from "effective-progress";
const program = Progress.all(
Array.from({ length: 5 }).map((_, i) =>
Effect.asVoid(
Progress.all(
Array.from({ length: 15 }).map((_) => Effect.sleep("100 millis")),
{ description: `Running subtasks for task ${i + 1}` },
),
),
),
{ description: "Running tasks in parallel", concurrency: 2 },
);
Effect.runPromise(program);Support for either/validate modes of Effect.all and render the amount of successes/failures.
Progress.allin default mode (mode: "default") remains fail-fast.- In fail-fast runs, unresolved units remain unprocessed.
mode: "either"andmode: "validate"run all effects and keep mixed outcomes in the task counters.- Mixed outcomes can finalize as
donewhen all units are accounted for. - Empty collections are valid inputs for
Progress.all/Progress.forEachand render as0/0instead of failing.
Use Progress.task(...) when you want one progress bar around a custom effect. The callback form gives you a task-local handle, so you can update counts, descriptions, and metadata without fetching the current task ID first.
import { Console, Effect } from "effect";
import * as Progress from "effective-progress";
const program = Progress.task(
(task) =>
Effect.gen(function* () {
yield* Console.log("Starting deployment");
yield* task.incrementSucceeded();
yield* task.update({
description: "Uploading release bundle",
});
yield* Effect.sleep("1 second");
yield* task.incrementSucceeded(2);
}),
{
description: "Deploy release",
total: 3,
},
);
Effect.runPromise(program);- The plain
Progress.task(effect, options)form auto-finalizes from the effect exit. - The callback form also auto-finalizes from the callback exit unless you explicitly
yield* task.completeoryield* task.failfirst. yield* Progress.Taskexposes the current task ID when you need it.
examples/simpleExample.ts- low-boilerplate real-world flowexamples/advancedExample.ts- mixed high-level and low-level Progress service usageexamples/basic.ts- minimalProgress.allusageexamples/nesting.ts- nested tree rendering with parent and child tasksexamples/mixedOutcomes.ts- fail-fast vseither/validatewith mixed success/failure countersexamples/cliProgressSemantics.ts- zero totals, negative totals clearing to unknown totals, overflow counts, and emptyall/forEachexamples/unknownTotalCounting.ts- count successes/failures without a known total and renderprocessed/?examples/typedMetadata.ts- typed task metadata rendered through custom columnsexamples/mixedNestedColumns.ts- different column sets aligned across mixed task typesexamples/showcase.ts- nested concurrent tasks, spinner workloads, and mixed Effect/Console loggingexamples/performance.ts- stress-style run with high log volume and deeply nested progress updatesexamples/performanceLong.ts- longer-running stress run with roughly 10x the work ofperformance.tsexamples/performanceComparison.ts- bare vs progress comparison for theperformance.tsworkloadexamples/performanceComparisonLong.ts- longer bare vs progress comparison for theperformanceLong.tsworkload
- The Ink renderer runs with
patchConsole: true, so console output is patched by Ink while the app is mounted. Progress.task,Progress.all, andProgress.forEachwrite through the currently provided EffectConsoleimplementation.- Formatting is controlled by the API consumer's logger/console implementation.
- Rendering is powered by Ink.
- Built-in columns are exposed as
Progress.Columns.description(),bar(),amount(),elapsed(),eta(),spacer(), anddefaults(). - Determinate bars are segmented by outcome: succeeded (green), failed (red), and remaining (neutral).
- Determinate amount text shows counters without prefixes:
<succeeded> <failed> <processed>/<total>. - Counts can exceed
total; the amount text keeps those raw values (for example12/10) while the bar stays visually clamped at full. total: 0is valid for determinate tasks and renders as a full bar by default.- Column widths are resolved per visual column index, so rows with different column definitions can still align with each other.
- Column
prepare(...)functions can compute shared layout data once for all rows using the same column definition at a given index. - On narrow terminals, layout compacts to fit available width and tree prefixes are suppressed when description space is too tight.
Progress.task(...) supports two styles:
Progress.task(effect, options)for the simple "wrap this effect in a task" case.Progress.task((task) => effect, options)when you want a typed handle for task-local control.
The handle exposes:
incrementSucceeded(amount?)incrementFailed(amount?)update({ description, total, countDisplay, transient, succeeded, failed })getMetadata,setMetadata,updateMetadatagetSnapshotcompletefail
When you need lower-level control, the Progress service is available inside the effect and exposes APIs like addTask, updateTask, incrementSucceeded(taskId, amount), and completeTask(taskId).
Example using the lower-level service API:
import { Console, Effect } from "effect";
import * as Progress from "effective-progress";
const program = Progress.task(
Effect.gen(function* () {
const progress = yield* Progress.Progress;
const currentTask = yield* Progress.Task;
yield* Console.log("This log is handled by the outer Console", { taskId: currentTask });
// Manual determinate updates:
yield* progress.incrementSucceeded(currentTask, 3);
yield* progress.incrementFailed(currentTask, 1);
yield* Effect.sleep("1 second");
}),
{ description: "Manual task", total: 10 },
);Manual total behavior:
- negative totals on task creation clear the total and switch to indeterminate rendering
- negative totals on later
updateTaskcalls also clear the total - explicit
total: undefinedonupdateTaskclears the total and switches back to indeterminate rendering
Tasks can carry typed metadata, and that metadata type flows into custom column renderers.
import { Effect } from "effect";
import * as Progress from "effective-progress";
interface EvalMeta {
readonly model: string;
readonly score: number;
}
const scoreColumn = (): Progress.ColumnDef<EvalMeta> => ({
align: "right",
flexShrink: 0,
minWidth: 5,
render: ({ task }) => `${task.metadata.score}%`,
});
const program = Progress.task(
(task) =>
Effect.gen(function* () {
yield* task.setMetadata({ model: "gpt-5.4", score: 91 });
yield* task.incrementSucceeded();
}),
{
description: "Run evaluation",
total: 1,
metadata: { model: "gpt-5.4", score: 0 },
columns: [
Progress.Columns.description(),
Progress.Columns.bar(),
{
flexShrink: 0,
minWidth: 10,
render: ({ task }) => task.metadata.model,
},
scoreColumn(),
Progress.Columns.elapsed(),
],
},
);ColumnDef<M, P> supports:
prepare(rows)to derive shared data for all matching rows at that column indexrender(cell, ctx)to render the cell- sizing hints with
flexGrow,flexShrink,flexBasis, andminWidth alignwith"left","center", or"right"
If a task does not provide columns, the renderer falls back to Progress.Columns.defaults().
- As Effect 4.0 is around the corner with some changes to logging, there may be some adjustments needed to align with the new Effect APIs.



