go-cli-progress · git:20260827.470b6dd · 2026-08-27 · sha256 b318f6678cd9fb61

go-cli-progress git:20260827.470b6ddA

Immutable. This exact content is served forever at /api/v1/blob/b318f6678cd9fb61.

---
name: go-cli-progress
description: Sequential running/done progress for Go CLI tools - phase, single-operation, multi-step, and check lifecycles plus the in-place progress bar. Use when a command runs a series of steps and should show what it is doing, when clearing terminal lines, or when adding a percentage bar. Triggers on PrintRunning, PrintIndentedSuccess, PrintIndentedError, ClearLines, ClearPreviousLine, PrintProgress, StdoutIsTerminal, and any command that prints "Running..." then replaces it with a result.
user-invocable: false
---

# Go CLI Progress

**Four lifecycles for showing sequential work in a CLI Only tool, and the in-place progress bar.**

These cover work that runs one step after another. A concurrent pipeline over many items uses a job pipeline with an aggregated display instead, because line clearing assumes one writer.

The contract underneath all four: redrawing is a terminal affordance, so transient lines are cleared only when stdout is a terminal and `--debug` is off. Everywhere else every step still gets announced and nothing is cleared, which leaves a log or a pipe holding the full progression instead of a stream of cursor escapes.

| Behavior | Terminal | Piped | `--debug` |
|---|---|---|---|
| Styled icons and colors | yes | glyphs kept, color stripped | no |
| `ClearLines` / `ClearPreviousLine` | clears | no-op | no-op |
| Progress bar | overwrites one line | one new line per tick | one zerolog entry per tick |
| Everything printed persists | no | yes | yes |

Steps are announced in every mode. What disappears outside a terminal is the redraw, never the fact that a step ran, because a step that failed is the one a log is read for.

## Line Clearing

```go
func ClearLines(n int) {
    if GlobalDebugFlag || !StdoutIsTerminal {
        return
    }
    for range n {
        fmt.Print("\033[A\033[2K")
    }
}

func ClearPreviousLine() {
    ClearLines(1)
}
```

The escape sequences go out through `fmt.Print` rather than a printer, since they are cursor control rather than content and the guard above has already established there is a cursor to control.

The count is always `lineCount + 1`, where the `+1` is the running header itself. Counting only the sub-lines leaves the header stranded above the summary that was meant to replace it.

## Phase Lifecycle

A phase is a named group of sequential sub-tasks. It prints a running header, prints indented results as they land, then clears everything and replaces it with one summary line. Collapsing a finished phase keeps the final screen proportional to what went wrong rather than to how much work was done.

While running:

```
↻ (Running) Phase 2: System packages
  ✓ tmux: installed system-managed
  ✓ openssl: already at system-managed
  ✗ nmap: apt install failed
```

After completion, with errors:

```
✗ Phase 2: partially completed with errors
  ✗ nmap: apt install failed
```

After completion, clean:

```
→ Phase 2: System packages
```

```go
func runPhase(phaseName string, tools []Tool) bool {
    if len(tools) == 0 {
        return false
    }
    utils.PrintRunning("(Running) " + phaseName)

    var lineCount int
    var errs []jobResult

    for _, t := range tools {
        version, err := install(t)
        if err != nil {
            utils.PrintIndentedError(t.Name, err)
            errs = append(errs, jobResult{name: t.Name, err: err})
        } else {
            utils.PrintIndentedSuccess(fmt.Sprintf("%s: installed %s", t.Name, version))
        }
        lineCount++
    }

    utils.ClearLines(lineCount + 1)

    if len(errs) > 0 {
        utils.PrintError(phaseName+": partially completed with errors", nil)
        for _, e := range errs {
            utils.PrintIndentedError(e.name, e.err)
        }
    } else {
        utils.PrintInfo(phaseName)
    }
    return len(errs) > 0
}
```

Only the failures are reprinted after the clear, so what stays on screen is what the user still has to act on.

## Single-Operation Lifecycle

One task with no sub-steps: print running, do the work, clear the one line, print the result.

```go
utils.PrintRunning("installing " + toolName)
result := inst.Install(tool)
utils.ClearLines(1)

if result.Err != nil {
    utils.PrintFatal(fmt.Sprintf("%s: install failed", toolName), result.Err)
}
utils.PrintSuccess(fmt.Sprintf("%s: installed %s", toolName, result.Version))
```

## Multi-Step Lifecycle

Several sequential steps, each announcing and clearing itself, with only the final result persisting. A self-update that checks a version, authenticates, and downloads reads as one operation to the user, so it leaves one line behind.

```go
utils.PrintRunning("checking latest version")
release, err := checkVersion()
utils.ClearLines(1)

utils.PrintRunning("authenticating sudo")
err = ensureSudo()
utils.ClearLines(1)

utils.PrintRunning(fmt.Sprintf("downloading %s", release.Tag))
err = download(release)
utils.ClearLines(1)

utils.PrintSuccess(fmt.Sprintf("updated: %s → %s", old, new))
```

## Check Lifecycle

A read-only scan over many items prints nothing per item, just one running indicator and then a summary. Per-item output during a check would scroll the findings off the screen before the user could read them.

```go
utils.PrintRunning("Checking tools")
results := checkAll(tools)
utils.ClearLines(1)

if len(results) == 0 {
    utils.PrintSuccess("everything is up to date")
    return
}

utils.PrintInfo("Check complete")
for _, r := range results {
    switch r.Status {
    case "update":
        utils.PrintIndentedWarn(fmt.Sprintf("%s: update available (%s → %s)", r.Name, r.Current, r.Latest), nil)
    case "error":
        utils.PrintIndentedError(fmt.Sprintf("%s: check failed", r.Name), r.Err)
    }
}
```

## Progress Bar

For one long operation whose completion percentage is known. A braille-dot bar overwrites a single line.

```
  ↻ video-3.mp4: ⣿⣿⣿⣿⣿⣀⣀⣀⣀⣀ 50%
```

```go
func PrintProgress(label string, percent int) {
    percent = min(percent, 100)

    if GlobalDebugFlag {
        log.Info().Int("percent", percent).Msg(label)
        return
    }
    if !StdoutIsTerminal {
        lipgloss.Println(fmt.Sprintf("  ↻ %s: %d%%", label, percent))
        return
    }

    const barWidth = 10
    filled := barWidth * percent / 100
    bar := strings.Repeat("⣿", filled) + strings.Repeat("⣀", barWidth-filled)
    lipgloss.Println(infoStyle.Render(fmt.Sprintf("  ↻ %s: %s %d%%", label, bar, percent)))
}
```

Debug mode logs the percentage as a structured field rather than a formatted string, so a log query can filter on it.

The caller runs a ticking goroutine that owns the line:

```go
done := make(chan struct{})
var printed atomic.Bool
var wg sync.WaitGroup

wg.Go(func() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    firstTick := true
    for {
        select {
        case <-done:
            return
        case <-ticker.C:
            if !firstTick {
                utils.ClearPreviousLine()
            }
            firstTick = false
            printed.Store(true)
            utils.PrintProgress("video-3.mp4", currentPercent)
        }
    }
})

encode(input, output)

close(done)
wg.Wait()
if printed.Load() {
    utils.ClearPreviousLine()
}
utils.PrintIndentedSuccess("video-3.mp4: encoded")
```

### Progress Rules

One progress line is active at a time, since two goroutines clearing lines will each erase the other's output.

The goroutine owns the line while it runs. The main goroutine closes `done`, waits on `wg` until the goroutine has returned, and only then clears the final line, because `close(done)` alone does not interrupt a tick already inside `PrintProgress`.

The `atomic.Bool` guards that final clear. Work that finishes before the first tick means the goroutine never printed, and clearing unconditionally would eat whatever line was above it.

A progress indicator inside a phase counts as 1 toward `lineCount`, not one per tick, since it overwrites itself. What counts is the success or error line the caller prints after cleanup.

One second is the default tick. Faster suits a short task and slower a long one, and anything under 250ms costs more in redraw than it conveys.

A piped stream gets one plain line per tick and never a clear, which is deliberate: whoever reads the output later sees the full progression rather than one final number.