Progress Bar Is Not an API

작성자

카테고리:

← 피드로
DEV Community · Minseok Song · 2026-07-10 개발(SW)

When a CLI becomes useful, it usually stops being something a person runs only by hand.

Someone puts it in a script. Then someone wraps it in a product workflow. Then another system starts asking a simple question: “What is happening right now?”

That is where a progress bar can become a problem.

For a person, this output is enough:

Translating markdown files  12/40  30%  docs/intro.md

Enter fullscreen mode Exit fullscreen mode

I can see that the command is alive. I can see how far it has moved. I can see the file it is working on.

But another program cannot safely build product state from that sentence. The moment it tries, the progress bar stops being just a UI. It becomes an accidental API.

That boundary was the real lesson behind one part of Co-op Translator v0.20.0. The release added a Rich-based progress UI for people, but it also added structured translation events for systems.

The practical problem was not how to print progress. It was how to keep human output and system state from collapsing into one unstable surface.

This post walks through the design in three steps:

First, why console output is so tempting to parse.

Second, why a better human UI can still break automation.

Third, how a shared event contract can serve the CLI, Python API, MCP, and product integrations such as Localizeflow.

Start with the simple version

Console output is the easiest progress interface to build.

When I run a translation command, I want to answer a few practical questions quickly:

  • Is the command still running?
  • Which stage is active?
  • Which file is being processed?
  • How much work is left?
  • Did anything fail?

A progress bar is good at this job. It compresses the current state of the run into something a person can scan.

That is the right first step. A CLI that gives no feedback during a long translation job feels broken, even when it is working.

The problem starts when the same text becomes the only place where state exists.

Imagine Localizeflow running Co-op Translator inside a larger workflow. It needs more than a readable line in the terminal. It needs durable facts:

  • Which translation job started
  • Which target language is active
  • Which stage is running
  • Which file completed
  • Which file failed
  • How many items are done
  • Whether the run succeeded

If those facts only exist inside display text, Localizeflow has to parse human language to recover machine state.

That is not a stable contract. It is a guess, and guesses become expensive once another product depends on them.

A better UI can still break automation

The awkward part is that the CLI can improve and still break the thing built around it.

Suppose an integration looks for this text:

Translating markdown files: 12/40

Enter fullscreen mode Exit fullscreen mode

Later, the CLI gets nicer. The same information moves into a Rich table, a progress bar, a status panel, or a shorter label.

For the person watching the command, that may be a clear improvement.

For a parser, it may be a failure.

Even a small wording change can be enough:

Retranslating outdated markdowns

Enter fullscreen mode Exit fullscreen mode

Then the label becomes clearer:

Retranslating outdated markdown files

Enter fullscreen mode Exit fullscreen mode

That change should be harmless. It is display text. It should be allowed to get better.

But if another system depends on the exact phrase, a UI edit becomes an integration breaking change.

Once you see that, the fix is not to make the progress bar easier to parse. The fix is to stop asking the progress bar to do two jobs.

Build state once, expose it twice

In v0.20.0, progress information is created once and then exposed through two different surfaces.

The first surface is the human renderer.

In the CLI, Rich can turn translation state into headers, tables, progress bars, and status lines. That output should be pleasant to read. It should also be free to change when a clearer display helps the person using the tool.

The second surface is the system event stream.

External integrations should not scrape console output. They should consume events with a stable schema.

The same progress can be represented as data:

{
  "schema": "co-op.translation.event.v1",
  "type": "stage_progress",
  "stage_key": "translating_markdown_files",
  "stage_label": "Translating markdown files",
  "language": "ko",
  "current_path": "docs/intro.md",
  "completed": 12,
  "total": 40,
  "progress": 30
}

Enter fullscreen mode Exit fullscreen mode

The progress bar and the event can come from the same internal state. They are just shaped for different readers.

The UI gets friendly labels and layout. The event gets stable fields that another system can store, compare, and replay.

Labels explain; keys promise

One small design choice matters a lot here: stage_label and stage_key are separate.

stage_label is for people:

{
  "stage_label": "Translating markdown files"
}

Enter fullscreen mode Exit fullscreen mode

That phrase can change if the CLI becomes clearer.

stage_key is for integrations:

{
  "stage_key": "translating_markdown_files"
}

Enter fullscreen mode Exit fullscreen mode

That value is the one another system should depend on.

This separation protects both sides. The CLI can keep improving its language, and integrations do not have to treat every wording change as a risk.

Progress is a workflow, not only a percentage

A useful event stream should not only report a number.

Progress is a workflow moving through states.

For Co-op Translator, that workflow can be expressed with events such as:

run_started
estimate_ready
stage_started
stage_progress
file_completed
run_completed

Enter fullscreen mode Exit fullscreen mode

Warnings and failures can use their own event types:

warning
file_failed
run_failed

Enter fullscreen mode Exit fullscreen mode

This makes the integration code simpler.

A dashboard can create a job when it receives run_started. It can show token estimates after estimate_ready. It can update visible progress after stage_progress. It can mark the job complete after run_completed.

The dashboard does not need to understand Co-op Translator’s terminal sentences.

It needs to understand the event contract.

One contract can serve several interfaces

Co-op Translator is not only a CLI.

It also has a Python API and an MCP server. That means the progress contract should not belong to one terminal renderer.

In the CLI, a person can watch the Rich progress UI. If another system needs machine-readable output, the CLI can write NDJSON events:

translate -l "ko ja" -md --json-events progress.ndjson

Enter fullscreen mode Exit fullscreen mode

In the Python API, callers can receive the same kind of events through a callback:

from co_op_translator.api import run_translation


def on_event(event):
    record_translation_event(job_id, event.to_dict())


run_translation(
    language_codes="ko ja",
    root_dir=".",
    markdown=True,
    progress_callback=on_event,
)

Enter fullscreen mode Exit fullscreen mode

In MCP, run_translation can return events in the tool result payload. Agents and host applications can then understand what happened without scraping terminal output.

The interfaces differ, but the contract is shared:

  • CLI: Rich renderer and NDJSON event file
  • Python API: progress_callback
  • MCP: event payload in the tool result

That is the practical value of the split. The display can evolve without destabilizing API and MCP integrations.

What changes for Localizeflow

From the perspective of Localizeflow, the difference is concrete.

In the fragile version, Localizeflow receives a sentence and tries to extract meaning from it:

Done: Translated README.md to Korean.

Enter fullscreen mode Exit fullscreen mode

From that sentence, it has to infer the file name, language, and completion state.

With structured events, it can store the fact directly:

{
  "type": "file_completed",
  "language": "ko",
  "current_path": "README.md"
}

Enter fullscreen mode Exit fullscreen mode

That event is easy to append to a database. Current job status can be materialized from the event stream. Logs can still exist, but they become supporting context rather than the source of truth for product state.

That is a healthier boundary. A person can read logs while investigating a run. The product can rely on events while managing the workflow.

Rich still matters

Separating events from the UI does not make the UI less important.

It makes the UI easier to improve.

A human-facing CLI should be clear. It should show the command, target language, estimate, current stage, current file, and failures in a form that is easy to scan.

Rich is useful for that:

  • Headers can group run information
  • Tables can organize estimates and stage progress
  • Progress bars can make long jobs easier to follow
  • GitHub Actions logs can remain reasonably readable

But Rich output should be allowed to change when a better display helps the user.

In this design, Rich renders the human-facing view. The versioned event schema carries the machine-readable state.

The two surfaces come from the same run, but they are accountable to different readers.

The principle

The lesson is simple:

Logs are for humans. Events are for systems.

People can read sentences and infer context. A small wording change is usually fine.

Systems need stable keys, schema versions, event types, and typed fields.

So when designing progress output, the first question should not be:

How do I make this text easier to parse?

The better question is:

Who is going to depend on this output?

If a person will read it, make the UI clear.

If another system will depend on it, provide versioned structured events.

A progress bar still matters. It makes a long-running CLI feel alive.

It just should not be the API.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다