npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

effective-progress

v0.15.0

Published

Effect-first terminal progress bars with nested multibar support

Downloads

1,644

Readme

effective-progress

npm version

[!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.

I am currently waiting on https://github.com/anomalyco/opentui/issues/204 to swap the renderer to opentui.

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 Effect v4 Effect.log* / Logger and Console.log while progress rendering is active
  • familiar .all and .forEach APIs — swap Effect for Progress, get progress bars basically for free
  • flicker-free rendering with Ink

Install

bun add effective-progress effect@^4.0.0-rc.112

Usage

Iterate items with a single progress bar.

import { 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* Effect.logInfo(`Completed task ${i + 1}`);
    }),
  ),
  { description: "Running tasks in parallel", concurrency: 2 },
);

Effect.runPromise(program);

Nested example

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);

Effect.all result mode

Progress.all mirrors Effect v4's fail-fast default and mode: "result", rendering the amount of successes and failures as work completes.

  • Progress.all in default mode (mode: "default") remains fail-fast.
  • In fail-fast runs, unresolved units remain unprocessed.
  • mode: "result" runs every effect and returns a Result for each outcome while keeping mixed outcomes in the task counters.
  • Result-mode tasks finalize as done when all units are accounted for.
  • Empty collections are valid inputs for Progress.all / Progress.forEach and render as 0/0 instead of failing.

Single task with a typed handle

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 { Effect } from "effect";
import * as Progress from "effective-progress";

const program = Progress.task(
  (task) =>
    Effect.gen(function* () {
      yield* Effect.logInfo("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.complete or yield* task.fail first.
  • yield* Progress.CurrentTask exposes the current task ID when you need it.

Examples

Run these from the repository root, starting with bun examples/single-task.ts:

| Order | Example | What it demonstrates | | ----- | -------------------------------------------- | ---------------------------------------------------- | | 1 | Single task | A spinner around one effect and automatic completion | | 2 | Collections | Progress.all, concurrency, and logging | | 3 | Nesting | Parent tasks and nested progress rows | | 4 | Mixed outcomes | Fail-fast and result-mode counters | | 5 | Custom columns | Typed metadata and column rendering |

The example catalog also covers complete workflows, manual task control, unknown totals, and other edge cases. Performance measurements and profiling workloads live in benchmarks/.

Configuration

Logging behavior

  • The Ink renderer runs with patchConsole: true, so console output is patched by Ink while the app is mounted.
  • Effect.log* uses the active Effect v4 Logger set, including custom loggers installed with Logger.layer(...).
  • Use Effect.log(...) or Effect.logInfo(...) directly; these honor the current log level, logger set, annotations, and spans.
  • Direct Console calls still use the currently provided Effect Console reference.
  • Formatting and routing remain controlled by the consumer's logger and console configuration.

For example, install the v4 pretty console logger around a program with:

import { Effect, Logger } from "effect";

Effect.runPromise(program.pipe(Effect.provide(Logger.layer([Logger.consolePretty()]))));

Ink renderer behavior

  • Rendering is powered by Ink.
  • Built-in columns are exposed as Progress.Columns.description(), bar(), amount(), elapsedEta(), elapsed(), eta(), spacer(), and defaults().
  • elapsedEta() renders a compact clock-style column as elapsed<eta using the shape 00:00<00:00; defaults() includes this combined column.
  • Determinate bars are segmented by outcome: succeeded (green), failed (red), and remaining (neutral).
  • bar() defaults to a fixed width of 30; pass bar({ size: "fullwidth" }) to consume remaining row width or bar({ size: 12 }) for an explicit width.
  • Determinate amount text shows counters without prefixes: <succeeded> <failed> <processed>/<total>.
  • Counts can exceed total; the amount text keeps those raw values (for example 12/10) while the bar stays visually clamped at full.
  • total: 0 is 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.

Task handles and the service

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, succeeded, failed })
  • getMetadata, setMetadata, updateMetadata
  • getSnapshot
  • complete
  • fail

Handle reads return Effect<Option<...>>: getMetadata yields Option<M> and getSnapshot yields Option<TaskSnapshot>. Removing a transient task (or its parent) makes both reads return None. A retained task with undefined metadata returns Some(undefined). Handle writes to removed tasks are no-ops, and updateMetadata does not invoke its callback for a removed task.

const metadata = yield * handle.getMetadata;
if (Option.isSome(metadata)) {
  // metadata.value has the metadata type inferred when the task was created.
}

Task mutation rules:

  • Completion and failure make later handle and service writes no-ops, including counter, field, and metadata updates. Metadata update callbacks are not invoked. Retained tasks remain readable through Some; metadata objects are not deep-frozen.
  • Counter values stay finite and nonnegative. Non-finite counter inputs preserve the previous value; if the resulting succeeded-plus-failed sum would overflow to infinity, both counter changes are ignored. Finite counts may still exceed the total, and negative finite values are clamped to zero.
  • Negative or non-finite totals become unknown.
  • A missing or removed parent ID creates a root task with parentId: null, without inheriting policies from the absent parent.

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).

The primary v4-style service layers are exposed as Progress.layer and ProgressStdio.layer.

Example using the lower-level service API:

import { 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.CurrentTask;
    yield* Effect.logInfo("Updating the current task", { 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 },
);

Task cleanup policy is fixed at creation. Pass transient: true when creating a task to remove its subtree when it finishes. Children inherit a transient parent’s cleanup policy, and a child can opt into transient cleanup under a persistent parent.

Manual total behavior:

  • negative or non-finite totals (NaN, Infinity, -Infinity) on task creation clear the total and switch to indeterminate rendering
  • negative or non-finite totals on later updateTask calls also clear the total
  • explicit total: undefined on updateTask clears the total and switches back to indeterminate rendering

Typed metadata and custom columns

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.Column<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(),
    ],
  },
);

Column<M, P> supports:

  • prepare(rows) to derive shared data for all matching rows at that column index
  • render(row, ctx) to render the cell
  • sizing hints with flexGrow, flexShrink, flexBasis, and minWidth
  • align with "left", "center", or "right"

Use Column<M, P> to author a column with typed metadata and prepared data, and AnyColumn<M> for a list containing columns with different prepared types. The renderer binds each prepared value to its definition before rendering cells.

If a task does not provide columns, the renderer falls back to Progress.Columns.defaults().

Clock hooks for custom cells

render(row, ctx) receives width and prepared. For animated or timed output, return a React component that calls useSpinnerTick() or useNow(). Hooks belong inside the component, not directly inside the column's render callback.

import { Text } from "ink";
import { useNow, type Column, type TaskSnapshot } from "effective-progress";

const AgeCell = ({ task }: { readonly task: TaskSnapshot }) => {
  const now = useNow(task.status === "running");
  const seconds = Math.floor(((task.completedAt ?? now) - task.startedAt) / 1_000);
  return <Text>{`${seconds}s`}</Text>;
};

const ageColumn: Column = {
  render: ({ task }) => <AgeCell task={task} />,
};

useNow follows the shared one-second clock; useSpinnerTick follows the shared spinner clock. Both accept an optional active boolean (default true). Passing false returns 0 without subscribing. Built-in cells unsubscribe when their task finishes. These hooks consume the progress renderer's providers; they do not create per-cell timers.

Performance benchmarks

Run bun run bench for isolated store-update and column-resolution measurements. Each fixture uses three warmup rounds and nine measured rounds, reporting JSON with raw samples, median/min/max time, throughput, and runtime information. Task setup, correctness assertions, explicit flushes, and console output are outside the timed sections; the benchmark does not render a terminal UI or add sleeps.

The store cases update one hot task in stores of 1, 100, and 1,000 tasks. Column cases resolve 100 and 1,000 rows using defaults or distinct custom prepare functions. Every round checks final counters or prepared/layout output and exits with an error on a mismatch.

To compare a change, run the same benchmark on both revisions with the same Bun version and machine, without other CPU-heavy work. Alternate revision order across multiple runs and compare medians and sample spread. These microbenchmarks measure store/resolver work; use the perf script and workloads in benchmarks/workloads/ separately to investigate Ink rendering, logging, and end-to-end overhead.

Run bun run bench:render for mounted React/Ink rendering measurements. It uses 100 rows with the four default columns, two warmup rounds and seven measured rounds of 30 frames each. Cases advance the spinner with all tasks or one task running, advance elapsed time with one task running, and update one task's counters. Each frame waits for Ink to flush. Debug mode disables output throttling and writes go to a sink, so timings include reconciliation, layout, and output generation but exclude physical terminal latency, real timer delays, and store publication. JSON includes raw timings and column callback counts; compare the same harness and runtime across revisions. Single-task frames also include row preparation.

Effect compatibility

This release targets Effect 4.0.0-rc.112 or newer compatible v4 prereleases. Effect v4 is a release candidate, so its APIs may change before the stable release.