Skip to content

Add progress bars and spinners for long-running CLI operations#149

Open
Copilot wants to merge 13 commits into
mainfrom
copilot/add-progress-bar-capability
Open

Add progress bars and spinners for long-running CLI operations#149
Copilot wants to merge 13 commits into
mainfrom
copilot/add-progress-bar-capability

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 30, 2026

Any operation taking >30s (container startup, Azure Bicep deployment, DB migration, pipeline sync) gave no visual feedback beyond a static message. Users had no way to gauge progress or distinguish a slow operation from a hang.

New: cmd/progress.go

Zero-dependency terminal progress UI with three primitives:

  • progressBar — in-place [████████░░░░░░░░] 8/36 waiting for DevLake (1m20s elapsed) via \r
  • countdown(n, label) — deterministic per-second fill bar for fixed-duration sleeps
  • spinWhile(label, fn) — runs fn in a goroutine, shows a braille spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) until it returns

Constants progressLineWidth = 72 and progressBarWidth = 24 keep rendering consistent across all callers.

Applied to long-running operations

Location Before After
waitForReady / waitForReadyAny / waitForMigration New line per poll attempt Single in-place progress bar
startLocalContainers — MySQL init sleep time.Sleep(30s) countdown(30, "MySQL initializing")
DeployBicep in deploy_azure.go Blocking call, no feedback spinWhile("Deploying Azure resources via Bicep", ...)
Azure MySQL restart sleep time.Sleep(30s) countdown(30, "waiting for MySQL")
triggerAndPoll in configure_projects.go New line every 10s with raw elapsed/status In-place [████░░░░] 3/8 tasks — TASK_RUNNING (45s elapsed)

Example — spinner wrapping Bicep deployment

var deployment *azure.DeploymentOutput
err = spinWhile("Deploying Azure resources via Bicep (this takes several minutes)", func() error {
    var innerErr error
    deployment, innerErr = azure.DeployBicep(azureRG, templatePath, params)
    return innerErr
})

On failure, spinWhile clears the spinner line before returning so the caller's error message lands on a clean line.

Original prompt

Add "progress bar" capability in the UI to Containers spinning up and long deployment ones like Azure deployment. Basically anything in the flow where a user has to wait longer than 30 seconds for something should ideally have a progress bar

The user has attached the following file paths as relevant context:

  • .github/copilot-instructions.md
  • AGENTS.md
  • AGENTS.md

Created from VS Code.

- New cmd/progress.go: progressBar struct (in-place fill bar with \r),
  countdown() for deterministic sleeps, spinWhile() for indeterminate
  waits (braille spinner). Constants progressLineWidth/progressBarWidth
  ensure consistent rendering across all callers.
- cmd/helpers.go: waitForReady, waitForReadyAny, waitForMigration now
  show an animated [████░░░░] progress bar instead of printing a new
  line per attempt.
- cmd/deploy_local.go: 30s MySQL init sleep replaced with countdown bar.
- cmd/deploy_azure.go: DeployBicep wrapped with spinWhile(); MySQL 30s
  sleep replaced with countdown().
- cmd/configure_projects.go: triggerAndPoll uses in-place \r updates
  with renderBar for task progress.

Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/5882daaa-5962-4400-851a-c131c3cb23f9

Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot AI changed the title [WIP] Add progress bar for long-running UI processes Add progress bars and spinners for long-running CLI operations Mar 30, 2026
Copilot AI requested a review from ewega March 30, 2026 16:00
@ewega ewega marked this pull request as ready for review May 6, 2026 09:37
Copilot AI review requested due to automatic review settings May 6, 2026 09:37
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a small, zero-dependency progress UI helper for the gh-devlake Cobra CLI and wires it into several long-running operations (container readiness polling, fixed sleeps, Azure Bicep deployment, and pipeline monitoring) to provide continuous terminal feedback instead of static messages.

Changes:

  • Added cmd/progress.go with an in-place progress bar, deterministic countdown bar, and a spinner wrapper for indeterminate waits.
  • Updated DevLake readiness + migration polling to render a single in-place progress bar instead of printing a new line per attempt.
  • Wrapped long sleeps and Azure deployment / pipeline monitoring with countdown/spinner/in-place progress output.
Show a summary per file
File Description
cmd/progress.go Adds progress bar, countdown, and spinner primitives for in-place terminal feedback.
cmd/helpers.go Uses the new progress bar for /ping readiness and migration polling loops.
cmd/deploy_local.go Replaces a fixed MySQL initialization sleep with a countdown bar during local deploy.
cmd/deploy_azure.go Adds countdown for MySQL start wait and wraps Bicep deployment in a spinner.
cmd/configure_projects.go Switches pipeline polling output to an in-place progress bar/status line.

Copilot's findings

  • Files reviewed: 5/5 changed files
  • Comments generated: 3

Comment thread cmd/progress.go
Comment on lines +14 to +56
const progressLineWidth = 72

// progressBarWidth is the number of block characters in every progress bar.
const progressBarWidth = 24

// progressBar renders an in-place terminal progress bar using \r.
// Create with newProgressBar, call update to redraw, and done to finish.
type progressBar struct {
total int
width int
start time.Time
}

func newProgressBar(total int) *progressBar {
return &progressBar{total: total, width: progressBarWidth, start: time.Now()}
}

// renderBar returns a [████░░░░] string representing current/total progress.
// Rounding is applied so early progress is visible even at low percentages.
func renderBar(current, total, width int) string {
if total <= 0 {
return "[" + strings.Repeat("░", width) + "]"
}
// Use rounding division to avoid premature completion or invisible early progress.
filled := (width*current + total/2) / total
if filled > width {
filled = width
}
return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]"
}

// update redraws the progress bar at position current.
// It uses \r to overwrite the current line in the terminal.
func (p *progressBar) update(current int, label string) {
bar := renderBar(current, p.total, p.width)
elapsed := time.Since(p.start).Truncate(time.Second)
fmt.Printf("\r %s %2d/%-2d %s (%s elapsed) ", bar, current, p.total, label, elapsed)
}

// clear erases the progress bar line and returns the cursor to column 0.
func (p *progressBar) clear() {
fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth))
}
Comment thread cmd/deploy_azure.go
Comment on lines +289 to +294
var deployment *azure.DeploymentOutput
err = spinWhile("Deploying Azure resources via Bicep (this takes several minutes)", func() error {
var innerErr error
deployment, innerErr = azure.DeployBicep(azureRG, templatePath, params)
return innerErr
})
Comment thread cmd/configure_projects.go
Comment on lines 460 to 465
if err != nil {
elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second)
fmt.Printf(" [%s] Could not check status...\n", elapsed)
fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed))
} else {
elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second)
fmt.Printf(" [%s] Status: %s | Tasks: %d/%d\n", elapsed, p.Status, p.FinishedTasks, p.TotalTasks)
bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth)
fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed))

@ewega
Copy link
Copy Markdown
Contributor

ewega commented May 15, 2026

@copilot Can you address the comments in this PR

…width spaces

Replace strings.Repeat(" ", progressLineWidth) clears with \033[2K (ANSI
erase entire line) in progress.go and configure_projects.go. This correctly
erases lines of any length regardless of label width, fixing garbled output
for long labels like the Azure Bicep spinner. Also removes the now-unneeded
progressLineWidth constant and trailing-space padding from all update/spinner
print calls.

Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 15, 2026

@copilot Can you address the comments in this PR

Addressed all three review comments in commit Fix progress/spinner rendering: use ANSI erase-line instead of fixed-width spaces.

The root cause was the same across all three: relying on overwriting with a fixed number of spaces (72) to "clear" a line, which fails for longer content.

Fix: replaced every strings.Repeat(" ", progressLineWidth) clear with the ANSI erase-entire-line sequence (\033[2K). This erases the full line regardless of what was printed there, so:

  • progressBar.clear() and spinWhile's completion/error clear now work for any label length (including the long Azure Bicep label).
  • clearLine() in configure_projects.go works correctly.
  • The status printf lines in configure_projects.go no longer need fixed-width %-*s padding — they now use \033[2K before each redraw instead.

The now-unneeded progressLineWidth = 72 constant was also removed.

Copilot finished work on behalf of ewega May 15, 2026 16:15
@ewega
Copy link
Copy Markdown
Contributor

ewega commented May 21, 2026

@copilot Please address the Code Review Agent findings from the latest review cycle:

  • Finding (blocking): cmd/progress.go update() prints variable-length lines without padding/clearing, while clear() only overwrites progressLineWidth (72). Result: stale characters remain when the new line is shorter.
    • Requested change: Pad every update() line to progressLineWidth (e.g. %-*s) and truncate if longer, so each write fully overwrites the previous.
  • Finding (blocking): cmd/deploy_azure.go spinner label is long enough that the line exceeds progressLineWidth (72). spinWhile clears with 72 spaces, so the line is not fully erased.
    • Requested change: Either shorten the label or have spinWhile measure and clear the actual rendered length.
  • Finding (blocking): cmd/configure_projects.go fmt.Printf("\r %-*s", progressLineWidth-3, ...) pads short lines but does not truncate long ones. Long pipeline-status strings overflow.
    • Requested change: Truncate to progressLineWidth-3 before formatting (e.g. rune-safe slice with ellipsis).

Validation: go build ./..., go vet ./..., go test ./.... Manual: confirm clean redraw on terminal width 80.

…s to progressLineWidth

Re-add progressLineWidth = 72 constant and add a rune-safe truncatePad helper
that pads short lines with spaces and truncates long lines with "…". Apply it
to update(), spinWhile ticker, and configure_projects.go status lines so every
in-place write occupies exactly the target column width. Replace ANSI \033[2K
clears with matching space-overwrite clears (reliable since every update now
writes exactly progressLineWidth chars). Truncate configure_projects pipeline
status to progressLineWidth-3 runes (accounting for the 3-space prefix) to
prevent overflow on terminals of width 80.

Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 21, 2026 17:14
…onfigure_projects.go

Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 21, 2026 17:15
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 21, 2026

@copilot Please address the Code Review Agent findings from the latest review cycle:

  • Finding (blocking): cmd/progress.go update() prints variable-length lines without padding/clearing, whil...

All three findings addressed in commits Fix progress rendering: add truncatePad, pad/truncate all status lines to progressLineWidth and Extract statusLineIndent = 3 constant:

  • progress.go update(): builds the full line as a string, then passes it through truncatePad(line, progressLineWidth) — a new rune-safe helper that pads short lines with spaces and truncates long lines with . Every update now writes exactly progressLineWidth runes, so clear() (which overwrites the same width with spaces) is always sufficient.
  • spinWhile / long labels: the spinner tick applies the same truncatePad(line, progressLineWidth), so any label length — including the Azure Bicep one — is capped before printing.
  • configure_projects.go status lines: each line is built as a string then passed through truncatePad(line, progressLineWidth-statusLineIndent), where the new statusLineIndent = 3 constant makes the accounting explicit and avoids repeated magic numbers.

Note: I kept the \r%s\r clear pattern rather than switching to \r%s — the trailing \r is load-bearing: it repositions the cursor to column 0 after the 72 spaces, so subsequent fmt.Println output starts on a clean line rather than at column 72.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 5/5 changed files
  • Comments generated: 3

Comment thread cmd/progress.go Outdated
Comment on lines +11 to +15
// progressLineWidth is the number of terminal columns each in-place status
// line occupies. Every update() and spinner tick writes exactly this many
// visible characters so that clear() (which overwrites the same width with
// spaces) always fully erases the previous line.
const progressLineWidth = 72
Comment thread cmd/progress.go
Comment on lines +68 to +73
func (p *progressBar) update(current int, label string) {
bar := renderBar(current, p.total, p.width)
elapsed := time.Since(p.start).Truncate(time.Second)
line := fmt.Sprintf(" %s %2d/%-2d %s (%s elapsed)", bar, current, p.total, label, elapsed)
fmt.Printf("\r%s", truncatePad(line, progressLineWidth))
}
Comment thread cmd/progress.go Outdated
Comment on lines +26 to +62
// truncatePad returns s padded with spaces to exactly width runes,
// or truncated to (width-1) runes followed by "…" if s is longer.
// Width is measured in runes to handle multi-byte characters correctly.
func truncatePad(s string, width int) string {
runes := []rune(s)
n := len(runes)
if n > width {
return string(runes[:width-1]) + "…"
}
return s + strings.Repeat(" ", width-n)
}

// progressBar renders an in-place terminal progress bar using \r.
// Create with newProgressBar, call update to redraw, and done to finish.
type progressBar struct {
total int
width int
start time.Time
}

func newProgressBar(total int) *progressBar {
return &progressBar{total: total, width: progressBarWidth, start: time.Now()}
}

// renderBar returns a [████░░░░] string representing current/total progress.
// Rounding is applied so early progress is visible even at low percentages.
func renderBar(current, total, width int) string {
if total <= 0 {
return "[" + strings.Repeat("░", width) + "]"
}
// Use rounding division to avoid premature completion or invisible early progress.
filled := (width*current + total/2) / total
if filled > width {
filled = width
}
return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]"
}
Copilot AI requested review from Copilot and removed request for Copilot May 22, 2026 09:59
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 22, 2026

@copilot Please address the Code Review Agent findings from the latest review cycle:

  • Finding (blocking): cmd/progress.go measures width in runes (truncatePad) but the UI assumption is *disp...

All three findings addressed in commit Fix progress rendering: use go-runewidth, derive indent from constant, add tests:

  • Display-width correctness: Added github.com/mattn/go-runewidth (no security vulnerabilities) and rewrote truncatePad to use runewidth.StringWidth for measurement and runewidth.Truncate(s, width, "…") for truncation — the tail is included in the width budget so lines are always exactly progressLineWidth display columns. progressLineWidth's doc comment now explicitly says "display columns, not runes".
  • Derive indent from constant: Added var statusLinePrefix = strings.Repeat(" ", statusLineIndent) and replaced every hardcoded " " in update(), done(), spinWhile(), and the two "\r " prints in configure_projects.go with statusLinePrefix.
  • Unit tests (cmd/progress_test.go): Tests for truncatePad (exact width, short/padded, empty, ASCII truncation, multi-byte rune, wide CJK 2-col chars, width-1) and renderBar (total≤0, total<0, current=0, current=total, current>total, half, rounding, width-1). All pass with go test ./....

Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 22, 2026 10:00
Copilot finished work on behalf of ewega May 22, 2026 10:00
@ewega ewega requested a review from Copilot May 22, 2026 10:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 7/8 changed files
  • Comments generated: 3

Comment thread cmd/progress.go Outdated
// Returns fn's error.
func spinWhile(label string, fn func() error) error {
done := make(chan error, 1)
go func() { done <- fn() }()
Comment thread cmd/progress.go
}
// Use rounding division to avoid premature completion or invisible early progress.
filled := (width*current + total/2) / total
if filled > width {
Comment thread go.mod
Comment on lines 5 to 14
require (
github.com/mattn/go-runewidth v0.0.23
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
golang.org/x/term v0.25.0
)

require (
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@ewega
Copy link
Copy Markdown
Contributor

ewega commented May 22, 2026

@copilot Round 2 — three findings:

  • Finding (blocking): renderBar in cmd/progress.go can panic if current is negative — filled becomes negative and strings.Repeat panics with a negative count.
    • Requested change: Clamp current to [0, total] (or at minimum clamp filled to >= 0) before strings.Repeat.
  • Finding (medium): spinWhile can hang forever if fn panics — the goroutine exits without signaling, and the select loop never receives.
    • Requested change: Wrap the goroutine body with defer recover(), convert panics to an error sent on the done channel (or close a panic channel), so the spinner always terminates.
  • Finding (informational): PR description claims "Zero-dependency" but go.mod now adds github.com/mattn/go-runewidth (and an indirect dep).
    • Requested change: Update the PR description to acknowledge the new dependency (recommended), or rework width handling to avoid it.

Validation: go build ./..., go vet ./..., go test ./....

Copilot AI and others added 2 commits May 22, 2026 11:23
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 22, 2026 11:24
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 22, 2026 11:25
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 22, 2026 11:26
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 22, 2026

@copilot Round 2 — three findings:

  • Finding (blocking): renderBar in cmd/progress.go can panic if current is negative — filled becomes negative and strings.Repeat panics with a neg...

Addressed in 095002c.

  • renderBar now clamps negative progress before computing the fill so it can’t panic.
  • spinWhile now recovers panics from fn and returns an error instead of hanging.
  • Added regression coverage for negative progress and spinner panic recovery.

Validation passed: go build ./..., go vet ./..., go test ./....

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 7/8 changed files
  • Comments generated: 3

Comment thread cmd/helpers.go
Comment on lines 179 to 196
func waitForReady(baseURL string, maxAttempts int, interval time.Duration) error {
httpClient := &http.Client{Timeout: 5 * time.Second}
bar := newProgressBar(maxAttempts)
for attempt := 1; attempt <= maxAttempts; attempt++ {
bar.update(attempt, "waiting for DevLake")
resp, err := httpClient.Get(baseURL + "/ping")
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
fmt.Println(" ✅ DevLake is responding!")
bar.done("✅ DevLake is responding!")
return nil
}
}
fmt.Printf(" Attempt %d/%d — waiting...\n", attempt, maxAttempts)
time.Sleep(interval)
}
bar.clear()
return fmt.Errorf("DevLake not ready after %d attempts — check logs", maxAttempts)
}
Comment thread cmd/progress.go Outdated
Comment on lines +84 to +99
func (p *progressBar) update(current int, label string) {
bar := renderBar(current, p.total, p.width)
elapsed := time.Since(p.start).Truncate(time.Second)
line := fmt.Sprintf("%s%s %2d/%-2d %s (%s elapsed)", statusLinePrefix, bar, current, p.total, label, elapsed)
fmt.Printf("\r%s", truncatePad(line, progressLineWidth))
}

// clear erases the progress bar line and returns the cursor to column 0.
func (p *progressBar) clear() {
fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth))
}

// done clears the bar and prints a completion message.
func (p *progressBar) done(msg string) {
p.clear()
fmt.Println(statusLinePrefix + msg)
Comment thread go.mod
Comment on lines 5 to 14
require (
github.com/mattn/go-runewidth v0.0.23
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
golang.org/x/term v0.25.0
)

require (
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Copilot finished work on behalf of ewega May 22, 2026 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants