diff --git a/README.md b/README.md
index 187cb889..cc4c558b 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ server.
-
+
A rendering of an example session, not a recording of one.
@@ -201,6 +201,14 @@ contract.
## Development
+Preview the CLI's spinner, replica progress, and nested health/drain waits with
+`go run ./scripts/ui-demo`; add `-fail` for a failed-healthcheck example. The
+demo simulates a deployment locally and never connects to a server.
+
+Interactive terminals show a live progress line. Pipes, CI, and `TERM=dumb`
+receive static progress updates. Set `ONEBOX_NO_ANIMATION=1` to use static
+output in a terminal; `NO_COLOR` disables colors independently.
+
```sh
just check # local pre-commit gate
just e2e # opt-in Docker end-to-end suite
diff --git a/docs/media/deploy-dark.svg b/docs/media/deploy-dark.svg
index f068ab3e..08bdafcf 100644
--- a/docs/media/deploy-dark.svg
+++ b/docs/media/deploy-dark.svg
@@ -1,4 +1,4 @@
-
+
-
+
@@ -34,7 +34,12 @@
plan sha256:9f21ab4c… · mode 0600 · expires 15m
risk migration · strong confirmation required
$ ob deploy --plan ob-plan.json --approval ob-approval.json
- verified checks/url 200 · X-App-Ready: yes
- serving r-0042 · r-0041 superseded
+ ✓ transfer 0.7s
+ ✓ job migrate 1.1s
+ ✓ web rolling ×2 4.1s
+ ✓ worker rolling 0.9s
+ ✓ verify 0.6s
+ ✓ activate 0.2s
+ ✓ deployed r-0042 in 8.4s (prev r-0041)
diff --git a/docs/media/deploy-light.svg b/docs/media/deploy-light.svg
index 7a567cff..87af561c 100644
--- a/docs/media/deploy-light.svg
+++ b/docs/media/deploy-light.svg
@@ -1,4 +1,4 @@
-
+
-
+
@@ -37,7 +37,12 @@
plan sha256:9f21ab4c… · mode 0600 · expires 15m
risk migration · strong confirmation required
$ ob deploy --plan ob-plan.json --approval ob-approval.json
- verified checks/url 200 · X-App-Ready: yes
- serving r-0042 · r-0041 superseded
+ ✓ transfer 0.7s
+ ✓ job migrate 1.1s
+ ✓ web rolling ×2 4.1s
+ ✓ worker rolling 0.9s
+ ✓ verify 0.6s
+ ✓ activate 0.2s
+ ✓ deployed r-0042 in 8.4s (prev r-0041)
diff --git a/go.mod b/go.mod
index 01c11204..5e4a790a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.27.0
require (
github.com/charmbracelet/lipgloss v1.1.0
+ github.com/charmbracelet/x/ansi v0.11.8
github.com/compose-spec/compose-go/v2 v2.14.0
github.com/distribution/reference v0.6.0
github.com/opencontainers/go-digest v1.0.0
@@ -20,7 +21,6 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
- github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go
index 32ec52b3..0ad5f201 100644
--- a/internal/engine/deploy.go
+++ b/internal/engine/deploy.go
@@ -274,7 +274,7 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l
if done["transfer"] {
e.logf("transfer: already complete (resume)")
} else {
- tr := e.ui.Step("transfer", false)
+ tr := e.ui.Step("transfer", true)
if localStagingDir == "" {
res, err := e.T.Run(ctx, "test -d "+q(remoteDir))
if err != nil || res.ExitCode != 0 {
@@ -414,7 +414,7 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l
}
e.progress("verification", "started", "")
- vf := e.ui.Step("verify", false)
+ vf := e.ui.Step("verify", true)
if err := e.Verify(ctx); err != nil {
vf(err)
e.progress("verification", "failed", "verification failed; inspect journal evidence")
@@ -430,7 +430,7 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l
e.progress("verification", "succeeded", "")
e.progress("activation", "started", "")
- fin := e.ui.Step("activate", false)
+ fin := e.ui.Step("activate", true)
if err := jw.Append(ctx, journal.Record{
Phase: "activation", Event: "intent", Detail: "release=" + releaseID,
}); err != nil {
@@ -719,12 +719,14 @@ func (e *Engine) releaseRoles(ctx context.Context, remoteCompose string) error {
for _, roleName := range e.Spec.ReleaseOrder() {
role := e.Spec.Workloads[roleName]
e.logf("release %s (%s)", roleName, role.Mode())
+ step := e.ui.Step(roleName+" "+role.Mode(), true)
var err error
if role.Mode() == "rolling" {
err = e.RollRole(ctx, roleName, remoteCompose)
} else {
err = e.RecreateRole(ctx, roleName, remoteCompose)
}
+ step(err)
if err != nil {
return fmt.Errorf("%s: %w", roleName, err)
}
diff --git a/internal/engine/recovery.go b/internal/engine/recovery.go
index 3b01686c..5b919c02 100644
--- a/internal/engine/recovery.go
+++ b/internal/engine/recovery.go
@@ -250,12 +250,14 @@ func (e *Engine) restoreReleaseRoles(ctx context.Context, previous *Engine, prev
continue
}
}
+ step := previous.ui.Step(roleName+" "+role.Mode(), true)
var err error
if role.Mode() == "rolling" {
err = previous.RollRole(ctx, roleName, composePath)
} else {
err = previous.RecreateRole(ctx, roleName, composePath)
}
+ step(err)
if err != nil {
return fmt.Errorf("restore %s: %w", roleName, err)
}
diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go
index 4c92628e..ff2d62d6 100644
--- a/internal/engine/recovery_test.go
+++ b/internal/engine/recovery_test.go
@@ -81,8 +81,9 @@ func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing.
}
return base(command)
}
+ var output bytes.Buffer
engine := New(testConfig(), testProject(t), target, Options{
- Out: &bytes.Buffer{}, Sleep: noSleep,
+ Out: &output, Sleep: noSleep,
Now: func() time.Time { return time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) },
})
seedInterruptedRecoveryState(t, engine)
@@ -99,6 +100,9 @@ func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing.
if !errors.As(err, &incomplete) || incomplete.Code() != "recovery_incomplete" || incomplete.Phase != "verify" {
t.Fatalf("first recovery error = %#v", err)
}
+ if strings.Count(output.String(), "✓ web rolling") != 1 {
+ t.Fatalf("restored workload must leave one completed step before verification: %s", output.String())
+ }
if _, err := release.ReadActivationCheckpoint(context.Background(), target, engine.Names()); err != nil {
t.Fatalf("failed recovery cleared checkpoint: %v", err)
}
diff --git a/internal/engine/roll.go b/internal/engine/roll.go
index 2a27ce26..3b14351a 100644
--- a/internal/engine/roll.go
+++ b/internal/engine/roll.go
@@ -135,6 +135,8 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
cc := e.composeCmdForProject(remoteComposePath, remoteProjectDir)
desired := role.Count()
within, pollEvery := role.ReadyTiming()
+ update, stop := e.ui.Progress(roleName+" rolling", desired)
+ defer stop()
pulled := false
// Each pass converges by one step: add a missing new replica, or retire a
@@ -149,6 +151,11 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
return err
}
olds := subtract(cur, news)
+ // A running newcomer alone is not a completed replacement: its old
+ // replica must have drained and retired too. Reserve the final segment
+ // until slot assignment has succeeded, including when resuming a roll.
+ completed := min(len(news), max(0, desired-len(olds)), max(0, desired-1))
+ update(completed, "inspecting replicas")
if guard > 4*(desired+len(olds))+8 {
return fmt.Errorf("role %s: roll did not converge (news=%d olds=%d)", roleName, len(news), len(olds))
}
@@ -158,6 +165,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
break
}
// surplus new replicas (count reduced) — drain one down to desired
+ update(completed, "retiring surplus replica")
if err := e.retireContainer(ctx, role, news[desired], pollEvery); err != nil {
return err
}
@@ -167,6 +175,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
// surge one new replica if we still need more of the new release
if len(news) < desired {
if !pulled {
+ update(completed, "checking image")
if err := e.pullBeforeRelease(ctx, svc, cc); err != nil {
return err
}
@@ -193,6 +202,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
}
known := idSet(news)
scale := len(cur) + 1
+ update(completed, fmt.Sprintf("starting replica %d", len(news)+1))
if res, err := e.mutate(ctx, fmt.Sprintf("%s up -d --no-deps --no-recreate --scale %s=%d %s", cc, svc, scale, svc)); err != nil {
return err
} else if res.ExitCode != 0 {
@@ -218,6 +228,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
return err
}
// join: the newcomer becomes a routable endpoint via its healthcheck
+ update(completed, fmt.Sprintf("replica %d healthcheck", len(news)+1))
if err := e.waitHealth(ctx, newID, "healthy", within, pollEvery); err != nil {
e.logf("join failed for %s — removing new container, existing keep serving", roleName)
cleanupErr := e.mutateChecked(ctx, "remove unhealthy newcomer "+newID, "docker rm -f "+newID)
@@ -227,12 +238,14 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
// retire one old, freeing its slot for the newcomer just added
if len(olds) > 0 {
+ update(completed, "retiring old replica")
if err := e.retireContainer(ctx, role, olds[0], pollEvery); err != nil {
return err
}
}
// hand clean slot names to any new container that doesn't have one yet
+ update(completed, "assigning replica slots")
if err := e.reslot(ctx, svc, releaseID, generation, desired); err != nil {
return err
}
@@ -240,6 +253,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose
if err := e.reslot(ctx, svc, releaseID, generation, desired); err != nil {
return err
}
+ update(desired, "replicas converged")
return nil
}
@@ -477,7 +491,8 @@ func subtract(all, remove []string) []string {
func (e *Engine) waitHealth(ctx context.Context, id, want string, budget, interval time.Duration) error {
deadline := e.Opts.Now().Add(budget)
- _, stop := e.ui.Busy(fmt.Sprintf("waiting %.12s → %s", id, want))
+ label := fmt.Sprintf("waiting %.12s → %s", id, want)
+ update, stop := e.ui.Busy(label)
defer stop()
for {
h, err := e.healthOf(ctx, id)
@@ -487,6 +502,7 @@ func (e *Engine) waitHealth(ctx context.Context, id, want string, budget, interv
if h == want {
return nil
}
+ update(label + " · " + h)
// A container that has exited will not report a different health
// status later, so waiting out the budget only delays the same answer
// with a worse message.
diff --git a/internal/engine/roll_test.go b/internal/engine/roll_test.go
index 56c6ee72..28879af4 100644
--- a/internal/engine/roll_test.go
+++ b/internal/engine/roll_test.go
@@ -269,6 +269,48 @@ func TestRollRoleTwoReplicasCleanSlots(t *testing.T) {
}
}
+func TestRollProgressCompletesOnlyAfterRetirementAndSlots(t *testing.T) {
+ f := rollFake()
+ var out bytes.Buffer
+ base := f.Dynamic
+ f.Dynamic = func(cmd string) (transport.Result, bool) {
+ if strings.HasPrefix(cmd, "docker stop ") || strings.HasPrefix(cmd, "docker rename ") {
+ if strings.Contains(out.String(), "web rolling · 1/1") {
+ t.Fatalf("progress completed before retirement/slot assignment: %s", out.String())
+ }
+ }
+ return base(cmd)
+ }
+ e := New(testConfig(), testProject(t), f, Options{Out: &out, Sleep: noSleep})
+ if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "web rolling · 1/1 · replicas converged") {
+ t.Fatalf("successful roll must finish measured progress: %s", out.String())
+ }
+}
+
+func TestRollProgressDoesNotCompleteOnFailedHealthcheck(t *testing.T) {
+ f := rollFake()
+ base := f.Dynamic
+ f.Dynamic = func(cmd string) (transport.Result, bool) {
+ if strings.Contains(cmd, "State.Health") && strings.Contains(cmd, "NEW1") {
+ return transport.Result{Stdout: "starting\n"}, true
+ }
+ return base(cmd)
+ }
+ cfg := testConfig()
+ cfg.Workloads["web"] = withinMillis(cfg.Workloads["web"], 1)
+ var out bytes.Buffer
+ e := New(cfg, testProject(t), f, Options{Out: &out, Sleep: noSleep})
+ if err := e.RollRole(context.Background(), "web", "F"); err == nil {
+ t.Fatal("expected failed healthcheck")
+ }
+ if strings.Contains(out.String(), "web rolling · 1/1") || strings.Contains(out.String(), "replicas converged") {
+ t.Fatalf("failed roll must not show full progress: %s", out.String())
+ }
+}
+
// drain.grace sets the docker stop -t timeout when retiring a drained container;
// absent it stays at the conservative 30s (asserted by the sequence tests).
func TestRollRoleDrainGraceConfigurable(t *testing.T) {
diff --git a/internal/engine/rollback_test.go b/internal/engine/rollback_test.go
index c2a2922c..a8d48d8c 100644
--- a/internal/engine/rollback_test.go
+++ b/internal/engine/rollback_test.go
@@ -82,6 +82,9 @@ func TestRollbackReplaysSnapshotChoreography(t *testing.T) {
if err := e.Rollback(context.Background()); err != nil {
t.Fatalf("rollback: %v\n%s", err, strings.Join(f.Commands, "\n"))
}
+ if strings.Count(out.String(), "✓ worker recreate") != 1 {
+ t.Fatalf("rollback must leave one completed workload step: %s", out.String())
+ }
seq := strings.Join(f.Commands, "\n")
if !strings.Contains(seq, "--force-recreate --timeout 30 worker") {
t.Fatalf("snapshot choreography (worker recreate) not replayed:\n%s", seq)
diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go
index 0a6f1877..75eac544 100644
--- a/internal/engine/secret_generation_rolling_test.go
+++ b/internal/engine/secret_generation_rolling_test.go
@@ -226,6 +226,9 @@ func TestSecretGenerationRollsRollingWorkloads(t *testing.T) {
if _, err := engine.SecretsPushBatch(context.Background(), generationPayloads()); err != nil {
t.Fatalf("push: %v\n%s", err, strings.Join(fake.Commands, "\n"))
}
+ if strings.Count(output.String(), "✓ web rolling") != 1 {
+ t.Fatalf("secret roll must leave one completed workload step: %s", output.String())
+ }
commands := strings.Join(fake.Commands, "\n")
webUp := upCommandsFor(fake.Commands, "web")
if len(webUp) == 0 {
@@ -263,6 +266,9 @@ func TestSecretGenerationRollingKeepsServingReplicaWhenNewcomerNeverHealthy(t *t
if _, err := engine.SecretsPushBatch(context.Background(), generationPayloads()); err == nil {
t.Fatalf("unhealthy newcomer must fail the push:\n%s", strings.Join(fake.Commands, "\n"))
}
+ if !strings.Contains(output.String(), "✗ web rolling") || strings.Contains(output.String(), "✓ web rolling") {
+ t.Fatalf("failed secret roll must leave a failed workload step: %s", output.String())
+ }
commands := strings.Join(fake.Commands, "\n")
if !strings.Contains(commands, "docker rm -f W2") {
t.Fatalf("unhealthy newcomer was not removed:\n%s", commands)
diff --git a/internal/engine/secretspush.go b/internal/engine/secretspush.go
index f865163d..2c58b566 100644
--- a/internal/engine/secretspush.go
+++ b/internal/engine/secretspush.go
@@ -740,7 +740,7 @@ func (e *Engine) cleanupOrphanSecretGenerations(ctx context.Context, releaseID s
return e.mutateChecked(ctx, "clean orphaned secret generations", command)
}
-func (e *Engine) forceSecretGeneration(ctx context.Context, checkpoint release.SecretCheckpoint, workload, generation string) error {
+func (e *Engine) forceSecretGeneration(ctx context.Context, checkpoint release.SecretCheckpoint, workload, generation string) (err error) {
// Already converged. Two paths reach here that way: a resume after a crash,
// and recovery after a roll whose unhealthy newcomer was removed without
// any old replica being touched. Neither has anything to replace, and
@@ -760,6 +760,8 @@ func (e *Engine) forceSecretGeneration(ctx context.Context, checkpoint release.S
return err
}
composePath := generationDir + "/compose.yaml"
+ step := e.ui.Step(workload+" "+e.Spec.Workloads[workload].Mode(), true)
+ defer func() { step(err) }()
// A rolling workload rotates its secret the way it takes a release: surge
// one replica on the new generation, gate it healthy, retire one old.
// Recreating instead destroyed every serving replica before the first
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index 8a65da54..e7d37098 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
"golang.org/x/term"
)
@@ -24,10 +25,10 @@ type UI struct {
verbose bool
now func() time.Time
- // one live spinner line at a time; println clears it so narrative and
- // spinner never collide, and the spinner repaints on its next tick
- spinLabel string
- spinOn bool
+ // Nested steps share one live line. A child wait keeps its parent's measured
+ // progress visible; stopping it restores the parent without showing the cursor.
+ busy []*busyState
+ width func() int
sHeader lipgloss.Style
sOK lipgloss.Style
@@ -35,6 +36,14 @@ type UI struct {
sWarn lipgloss.Style
sDim lipgloss.Style
sBold lipgloss.Style
+ sActive lipgloss.Style
+}
+
+type busyState struct {
+ label, detail string
+ completed, total int
+ started time.Time
+ stopped bool
}
func New(out io.Writer, verbose bool) *UI {
@@ -43,24 +52,38 @@ func New(out io.Writer, verbose bool) *UI {
if f, ok := out.(*os.File); ok {
tty = term.IsTerminal(int(f.Fd()))
}
+ tty = tty && animationAllowed()
return &UI{
out: out,
tty: tty,
verbose: verbose,
now: time.Now,
+ width: func() int {
+ if f, ok := out.(*os.File); ok {
+ if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 {
+ return w
+ }
+ }
+ return 80
+ },
sHeader: r.NewStyle().Bold(true),
sOK: r.NewStyle().Foreground(lipgloss.Color("2")),
sFail: r.NewStyle().Foreground(lipgloss.Color("1")),
sWarn: r.NewStyle().Foreground(lipgloss.Color("3")),
sDim: r.NewStyle().Faint(true),
sBold: r.NewStyle().Bold(true),
+ sActive: r.NewStyle().Foreground(lipgloss.Color("6")),
}
}
func (u *UI) println(s string) {
u.mu.Lock()
defer u.mu.Unlock()
- if u.spinOn {
+ u.printlnLocked(s)
+}
+
+func (u *UI) printlnLocked(s string) {
+ if len(u.busy) > 0 {
_, _ = io.WriteString(u.out, "\r\x1b[K") // clear the spinner line first
}
_, _ = io.WriteString(u.out, s+"\n")
@@ -69,6 +92,9 @@ func (u *UI) println(s string) {
// Header opens a section: ── title ───────────────
func (u *UI) Header(title string) {
line := "── " + title + " " + strings.Repeat("─", max(0, 50-len(title)))
+ if u.tty {
+ line = ansi.Truncate(line, max(1, u.width()-1), "…")
+ }
u.println(u.sHeader.Render(line))
}
@@ -91,10 +117,10 @@ func (u *UI) Begin(label string) {
// Done closes a step with its outcome and duration.
func (u *UI) Done(label string, d time.Duration, err error) {
if err != nil {
- u.println(u.sFail.Render("✗ "+label) + u.sDim.Render(" "+FmtDur(d)))
+ u.println(u.sFail.Render("✗") + " " + label + u.sDim.Render(" "+FmtDur(d)))
return
}
- u.println(u.sOK.Render("✓ "+label) + u.sDim.Render(" "+FmtDur(d)))
+ u.println(u.sOK.Render("✓") + " " + label + u.sDim.Render(" "+FmtDur(d)))
}
// Step times a step: call the returned func with the outcome. Announced steps
@@ -173,17 +199,22 @@ func (u *UI) Diff(diff string) {
}
}
-var spinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
+var spinFrames = []string{"▖", "▘", "▝", "▗"}
const (
hideCursor = "\x1b[?25l"
showCursor = "\x1b[?25h"
)
-// RestoreCursor re-shows the terminal cursor if w is a TTY — for signal
+func animationAllowed() bool {
+ return os.Getenv("TERM") != "dumb" && os.Getenv("ONEBOX_NO_ANIMATION") == "" &&
+ (os.Getenv("CI") == "" || os.Getenv("CI") == "false")
+}
+
+// RestoreCursor re-shows the terminal cursor if w is an animated TTY — for signal
// handlers: an interrupt mid-spinner must not leave the terminal cursorless.
func RestoreCursor(w io.Writer) {
- if f, ok := w.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
+ if f, ok := w.(*os.File); ok && term.IsTerminal(int(f.Fd())) && animationAllowed() {
_, _ = io.WriteString(w, showCursor)
}
}
@@ -193,41 +224,62 @@ func RestoreCursor(w io.Writer) {
// a TTY it prints one ⟳ line per distinct label — CI logs stay line-honest.
// update relabels the spinner; stop erases it.
func (u *UI) Busy(label string) (update func(string), stop func()) {
+ set, stop := u.startBusy(label, 0)
+ return func(label string) { set(0, label) }, stop
+}
+
+// Progress displays completed units out of a known total. Small totals get one
+// segment per unit; larger totals use a compact ten-segment bar. A total <= 0
+// falls back to a spinner. Off a TTY, only distinct count/detail updates print.
+// Completion of the units does not imply success: the caller still owns Done.
+func (u *UI) Progress(label string, total int) (update func(int, string), stop func()) {
+ return u.startBusy(label, max(0, total))
+}
+
+func (u *UI) startBusy(label string, total int) (func(int, string), func()) {
+ s := &busyState{label: label, total: total, started: u.now()}
if !u.tty {
- u.Begin(label)
- last := label
- return func(l string) {
- if l != last {
- u.Begin(l)
- last = l
+ last := plainBusy(s)
+ u.Begin(last)
+ return func(completed int, text string) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if s.stopped {
+ return
+ }
+ setBusy(s, completed, text)
+ if line := plainBusy(s); line != last {
+ u.printlnLocked(u.sDim.Render("⟳ " + line))
+ last = line
+ }
+ }, func() {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ s.stopped = true
}
- }, func() {}
}
u.mu.Lock()
- u.spinLabel, u.spinOn = label, true
- _, _ = io.WriteString(u.out, hideCursor) // the blinking cursor at line end is just noise
+ if len(u.busy) == 0 {
+ _, _ = io.WriteString(u.out, hideCursor)
+ }
+ u.busy = append(u.busy, s)
+ u.renderBusyLocked(0)
u.mu.Unlock()
- started := u.now()
done := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(finished)
t := time.NewTicker(120 * time.Millisecond)
defer t.Stop()
- i := 0
+ i := 1
for {
select {
case <-done:
- u.mu.Lock()
- _, _ = io.WriteString(u.out, "\r\x1b[K"+showCursor)
- u.spinOn = false
- u.mu.Unlock()
return
case <-t.C:
u.mu.Lock()
- if u.spinOn {
- line := fmt.Sprintf("%s %s · %s elapsed", spinFrames[i%len(spinFrames)], u.spinLabel, FmtDur(u.now().Sub(started)))
- _, _ = io.WriteString(u.out, "\r\x1b[K"+u.sDim.Render(line))
+ if len(u.busy) > 0 && u.busy[len(u.busy)-1] == s {
+ u.renderBusyLocked(i)
}
u.mu.Unlock()
i++
@@ -235,11 +287,99 @@ func (u *UI) Busy(label string) (update func(string), stop func()) {
}
}()
var once sync.Once
- return func(l string) {
+ return func(completed int, text string) {
u.mu.Lock()
- u.spinLabel = l
+ if !s.stopped {
+ setBusy(s, completed, text)
+ u.renderBusyLocked(0)
+ }
u.mu.Unlock()
}, func() {
- once.Do(func() { close(done); <-finished })
+ once.Do(func() {
+ u.mu.Lock()
+ s.stopped = true
+ for i, item := range u.busy {
+ if item == s {
+ u.busy = append(u.busy[:i], u.busy[i+1:]...)
+ break
+ }
+ }
+ if len(u.busy) == 0 {
+ _, _ = io.WriteString(u.out, "\r\x1b[K"+showCursor)
+ } else {
+ u.renderBusyLocked(0)
+ }
+ u.mu.Unlock()
+ close(done)
+ <-finished
+ })
+ }
+}
+
+func setBusy(s *busyState, completed int, text string) {
+ if s.total > 0 {
+ s.completed, s.detail = min(max(0, completed), s.total), text
+ } else {
+ s.label = text
+ }
+}
+
+func plainBusy(s *busyState) string {
+ line := s.label
+ if s.total > 0 {
+ line += fmt.Sprintf(" · %d/%d", s.completed, s.total)
+ if s.detail != "" {
+ line += " · " + s.detail
+ }
+ }
+ return line
+}
+
+func (u *UI) renderBusyLocked(frame int) {
+ _, _ = io.WriteString(u.out, "\r\x1b[K"+u.busyLineLocked(frame, max(1, u.width()-1)))
+}
+
+// Keep one cell free to avoid automatic wrapping at the terminal's right edge.
+// Timing and counts survive truncation; only the narrative text is shortened.
+func (u *UI) busyLineLocked(frame, width int) string {
+ current := u.busy[len(u.busy)-1]
+ display := current
+ for i := len(u.busy) - 1; i >= 0; i-- {
+ if u.busy[i].total > 0 {
+ display = u.busy[i]
+ break
}
+ }
+ detail := display.detail
+ if current != display {
+ detail = current.label
+ }
+ text := display.label
+ if detail != "" {
+ text += " · " + detail
+ }
+ text = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(text)
+ prefix := u.sActive.Render(spinFrames[frame%len(spinFrames)]) + " "
+ suffix := u.sDim.Render(" " + FmtDur(u.now().Sub(display.started)))
+ if display.total > 0 {
+ count := fmt.Sprintf(" %d/%d", display.completed, display.total)
+ bar := ""
+ if width >= 60 && display.total > 1 {
+ segments := min(display.total, 10)
+ filled := int(float64(display.completed) / float64(display.total) * float64(segments))
+ bar = u.sActive.Render(strings.Repeat("■", filled)) + u.sDim.Render(strings.Repeat("□", segments-filled))
+ }
+ suffix = " " + bar + u.sDim.Render(count) + suffix
+ }
+ available := width - lipgloss.Width(prefix) - lipgloss.Width(suffix)
+ if available < 1 {
+ // Drop narrative, spinner, and padding before measured information.
+ // When even the compact form cannot fit, prioritize the count.
+ compact := FmtDur(u.now().Sub(display.started))
+ if display.total > 0 {
+ compact = fmt.Sprintf("%d/%d %s", display.completed, display.total, compact)
+ }
+ return u.sDim.Render(ansi.Truncate(compact, width, "…"))
+ }
+ return prefix + ansi.Truncate(text, available, "…") + suffix
}
diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go
index 1ce28d69..dc8632bd 100644
--- a/internal/ui/ui_test.go
+++ b/internal/ui/ui_test.go
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/charmbracelet/lipgloss"
)
func TestPlainWriterGetsNoANSI(t *testing.T) {
@@ -148,3 +150,156 @@ func TestBusyNonTTY(t *testing.T) {
t.Fatalf("busy labels must print once each:\n%s", s)
}
}
+
+func TestProgressNonTTYReportsDistinctMeasuredCounts(t *testing.T) {
+ var out bytes.Buffer
+ u := New(&out, false)
+ update, stop := u.Progress("server rolling", 3)
+ update(-1, "starting replica 1")
+ update(2, "draining old replica")
+ update(2, "draining old replica")
+ update(99, "replicas converged")
+ stop()
+ update(1, "late update")
+ stop()
+ s := out.String()
+ if strings.ContainsAny(s, "\r\x1b") || strings.ContainsAny(s, "■□") {
+ t.Fatalf("plain progress must stay line-oriented: %q", s)
+ }
+ for _, want := range []string{"server rolling · 0/3", "server rolling · 2/3", "server rolling · 3/3"} {
+ if !strings.Contains(s, want) {
+ t.Fatalf("missing %q in %s", want, s)
+ }
+ }
+ if strings.Count(s, "draining old replica") != 1 || strings.Contains(s, "late update") {
+ t.Fatalf("duplicate or stopped progress update: %s", s)
+ }
+}
+
+func TestProgressUnknownTotalFallsBackToSpinner(t *testing.T) {
+ var out bytes.Buffer
+ u := New(&out, false)
+ update, stop := u.Progress("packing archive", 0)
+ defer stop()
+ update(42, "waiting for remote extraction")
+ if s := out.String(); strings.Contains(s, "/0") || !strings.Contains(s, "waiting for remote extraction") {
+ t.Fatalf("unknown total must not invent a percentage: %s", s)
+ }
+}
+
+func TestNestedBusyKeepsProgressAndRestoresParent(t *testing.T) {
+ var out bytes.Buffer
+ u := New(&out, false)
+ u.tty = true
+ _, stopStep := u.Busy("server rolling ×3")
+ defer stopStep()
+ update, stopProgress := u.Progress("server rolling", 3)
+ defer stopProgress()
+ update(2, "assigning slots")
+ _, stopWait := u.Busy("replica 3 healthcheck")
+ defer stopWait()
+ u.mu.Lock()
+ line := u.busyLineLocked(0, 79)
+ u.mu.Unlock()
+ for _, want := range []string{"server rolling", "■■□", "2/3", "replica 3 healthcheck"} {
+ if !strings.Contains(line, want) {
+ t.Fatalf("nested wait lost %q: %s", want, line)
+ }
+ }
+ u.Infof("an interleaved log")
+ stopWait()
+ u.mu.Lock()
+ line = u.busyLineLocked(0, 79)
+ shown := strings.Contains(out.String(), showCursor)
+ u.mu.Unlock()
+ if shown || strings.Contains(line, "healthcheck") || !strings.Contains(line, "assigning slots") {
+ t.Fatalf("child stop must restore its parent with cursor hidden: %s", line)
+ }
+ stopProgress()
+ u.mu.Lock()
+ line = u.busyLineLocked(0, 79)
+ u.mu.Unlock()
+ if !strings.Contains(line, "server rolling ×3") || strings.Contains(line, "2/3") {
+ t.Fatalf("progress stop did not restore the outer step: %s", line)
+ }
+ stopStep()
+ if strings.Count(out.String(), hideCursor) != 1 || strings.Count(out.String(), showCursor) != 1 {
+ t.Fatalf("nested spinners must share cursor ownership: %q", out.String())
+ }
+}
+
+func TestStoppingOuterBusyDoesNotEraseChild(t *testing.T) {
+ var out bytes.Buffer
+ u := New(&out, false)
+ u.tty = true
+ updateOuter, stopOuter := u.Busy("outer step")
+ defer stopOuter()
+ _, stopChild := u.Busy("child wait")
+ defer stopChild()
+ stopOuter()
+ updateOuter("late outer update")
+ u.mu.Lock()
+ line := u.busyLineLocked(0, 79)
+ shown := strings.Contains(out.String(), showCursor)
+ u.mu.Unlock()
+ if shown || !strings.Contains(line, "child wait") || strings.Contains(line, "late outer") {
+ t.Fatalf("outer stop must leave child active: %s", line)
+ }
+ stopChild()
+ if strings.Count(out.String(), showCursor) != 1 {
+ t.Fatalf("cursor must return once the last activity stops: %q", out.String())
+ }
+}
+
+func TestBusyLineFitsNarrowAndUnicodeTerminals(t *testing.T) {
+ u := New(&bytes.Buffer{}, false)
+ u.busy = []*busyState{{
+ label: "server 界界界界界界界界界界 rolling", detail: "healthcheck\nstarting",
+ completed: 2, total: 3, started: u.now().Add(-12 * time.Second),
+ }}
+ for _, width := range []int{1, 8, 20, 40, 79} {
+ line := u.busyLineLocked(0, width)
+ if lipgloss.Width(line) > width || strings.ContainsAny(line, "\r\n") {
+ t.Fatalf("width %d: spinner wraps or contains newlines: %q", width, line)
+ }
+ if width >= 8 && (!strings.Contains(line, "2/3") || !strings.Contains(line, "12s")) {
+ t.Fatalf("width %d: measured progress and timing must survive truncation: %s", width, line)
+ }
+ }
+}
+
+func TestAnimationControls(t *testing.T) {
+ for _, tc := range []struct {
+ name, term, ci, noAnimation string
+ want bool
+ }{
+ {"interactive", "xterm-256color", "", "", true},
+ {"ci-false", "xterm-256color", "false", "", true},
+ {"ci", "xterm-256color", "true", "", false},
+ {"ci-uppercase-false", "xterm-256color", "FALSE", "", false},
+ {"ci-zero", "xterm-256color", "0", "", false},
+ {"ci-present", "xterm-256color", "provider", "", false},
+ {"dumb", "dumb", "", "", false},
+ {"static", "xterm-256color", "", "1", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("TERM", tc.term)
+ t.Setenv("CI", tc.ci)
+ t.Setenv("ONEBOX_NO_ANIMATION", tc.noAnimation)
+ if got := animationAllowed(); got != tc.want {
+ t.Fatalf("animationAllowed() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestHeaderFitsInteractiveTerminal(t *testing.T) {
+ var out bytes.Buffer
+ u := New(&out, false)
+ u.tty = true
+ u.width = func() int { return 24 }
+ u.Header("deploy catalog → production · R42")
+ if line := strings.TrimSuffix(out.String(), "\n"); lipgloss.Width(line) > 23 {
+ t.Fatalf("header wraps in a narrow terminal: %q", line)
+ }
+}
diff --git a/scripts/ui-demo/main.go b/scripts/ui-demo/main.go
new file mode 100644
index 00000000..e2e18567
--- /dev/null
+++ b/scripts/ui-demo/main.go
@@ -0,0 +1,105 @@
+// Run with `go run ./scripts/ui-demo` in a terminal. This simulates deployment
+// output through the real UI renderer; it never contacts or modifies a host.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/labstack/onebox/internal/ui"
+)
+
+func main() {
+ fail := flag.Bool("fail", false, "show a failed healthcheck and recovery message")
+ flag.Parse()
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+ u := ui.New(os.Stdout, false)
+ err := demo(ctx, u, *fail)
+ ui.RestoreCursor(os.Stdout)
+ if err != nil {
+ u.Failf("%s", err)
+ os.Exit(1)
+ }
+}
+
+func demo(ctx context.Context, u *ui.UI, fail bool) error {
+ started := time.Now()
+ u.Header("deploy catalog → production · R42")
+ u.Infof("Simulated deployment")
+ for _, step := range []string{"transfer", "job migrate"} {
+ done := u.Step(step, true)
+ err := wait(ctx, 700*time.Millisecond)
+ done(err)
+ if err != nil {
+ return err
+ }
+ }
+ if err := roll(ctx, u, fail); err != nil {
+ return err
+ }
+ for _, step := range []string{"verify", "activate"} {
+ done := u.Step(step, true)
+ err := wait(ctx, 650*time.Millisecond)
+ done(err)
+ if err != nil {
+ return err
+ }
+ }
+ u.Successf("deployed R42 in %s", ui.FmtDur(time.Since(started)))
+ return nil
+}
+
+func roll(ctx context.Context, u *ui.UI, fail bool) (err error) {
+ done := u.Step("server rolling ×3", true)
+ defer func() { done(err) }()
+ update, stop := u.Progress("server rolling", 3)
+ defer stop()
+ for replica := 1; replica <= 3; replica++ {
+ update(replica-1, fmt.Sprintf("starting replica %d", replica))
+ if err := wait(ctx, 350*time.Millisecond); err != nil {
+ return err
+ }
+ _, stopWait := u.Busy(fmt.Sprintf("replica %d · waiting for healthcheck", replica))
+ err = wait(ctx, 850*time.Millisecond)
+ stopWait()
+ if err != nil {
+ return err
+ }
+ if fail && replica == 2 {
+ u.Warnf("Existing replicas continue serving traffic")
+ return errors.New("replica 2 healthcheck failed; inspect the service, then use ob resume or ob abort")
+ }
+ _, stopDrain := u.Busy("draining old replica")
+ err = wait(ctx, 600*time.Millisecond)
+ stopDrain()
+ if err != nil {
+ return err
+ }
+ update(replica, "replica replaced")
+ if replica == 1 {
+ u.Infof("server: replica 1 replaced; existing traffic remains available")
+ }
+ if err := wait(ctx, 250*time.Millisecond); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func wait(ctx context.Context, d time.Duration) error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
diff --git a/site/src/components/landing/Hero.astro b/site/src/components/landing/Hero.astro
index d4fec656..0910c2d5 100644
--- a/site/src/components/landing/Hero.astro
+++ b/site/src/components/landing/Hero.astro
@@ -61,12 +61,14 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {};
type the release id to confirm: r-0042
$ ob deploy --plan ob-plan.json --approval ob-approval.json
-staged r-0042
+✓ transfer 0.7s
migrate provider atlas · 1 revision · changed=true
-rolling shop-web-1 … healthy
-rolling shop-web-2 … healthy
-verified checks/url 200 · X-App-Ready: yes
-serving r-0042 · r-0041 superseded
+✓ job migrate 1.1s
+✓ web rolling ×2 4.1s
+✓ worker rolling 0.9s
+✓ verify 0.6s
+✓ activate 0.2s
+✓ deployed r-0042 in 8.4s (prev r-0041)
@@ -192,6 +194,7 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {};
--term-text: #cad4c6;
--term-muted: #788373;
--term-green: #8ed879;
+ --term-active: #78d9db;
--term-warn: #e2b75d;
border: 1px solid var(--term-border);
border-radius: 8px;
@@ -263,7 +266,7 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {};
font-weight: 400;
}
- .ob-term .dim {
+ :global([data-ob-transcript] .dim) {
color: var(--term-muted);
}
@@ -275,6 +278,10 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {};
color: var(--term-green);
}
+ :global([data-ob-transcript] .active) {
+ color: var(--term-active);
+ }
+
.ob-term .warn {
color: var(--term-warn);
}
@@ -344,11 +351,29 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {};
let i = 0;
const step = () => {
if (i >= lines.length) return;
- lines[i]?.removeAttribute("data-pending");
+ const line = lines[i];
+ line?.removeAttribute("data-pending");
i += 1;
- // Fast enough that the hero is not a blank box on arrival — the whole
- // transcript lands in under two seconds — but still paced, because the
- // point is that a deploy reports itself rather than appearing.
+ // Show measured replica progress briefly, then replace that same line
+ // with its completed step. No-JS and reduced-motion visitors see the
+ // completed transcript directly.
+ if (line?.hasAttribute("data-ob-roll")) {
+ const completed = line.innerHTML;
+ const frames = ["▖", "▘", "▝", "▗"];
+ let frame = 0;
+ const render = () => {
+ line.innerHTML = `${frames[frame % frames.length]} web rolling · healthcheck ■ □ 1/2 3.2s `;
+ frame += 1;
+ };
+ render();
+ const timer = window.setInterval(render, 120);
+ window.setTimeout(() => {
+ window.clearInterval(timer);
+ line.innerHTML = completed;
+ step();
+ }, 960);
+ return;
+ }
window.setTimeout(step, i < 10 ? 55 : 90);
};
step();
diff --git a/site/src/content/docs/start/first-deploy.mdx b/site/src/content/docs/start/first-deploy.mdx
index 8592a9d2..c06e4f5d 100644
--- a/site/src/content/docs/start/first-deploy.mdx
+++ b/site/src/content/docs/start/first-deploy.mdx
@@ -140,16 +140,16 @@ read-only.
ob deploy --plan ob-plan.json --approval ob-approval.json
```
- A successful deploy ends by naming the release that is serving and the one it
- superseded:
+ Completed steps stay in the terminal with their durations. This condensed
+ example shows a successful deploy, ending with its release and predecessor:
```console
- staged r-0042
- rolling shop-web-1 … healthy
- rolling shop-web-2 … healthy
- retain shop-worker · runtime unchanged
- verified checks/url 200 · X-App-Ready: yes
- serving r-0042 · r-0041 superseded
+ ✓ transfer 0.7s
+ ✓ web rolling ×2 4.1s
+ ✓ worker retain 0.0s
+ ✓ verify 0.6s
+ ✓ activate 0.2s
+ ✓ deployed r-0042 in 6.8s (prev r-0041 — `ob rollback`)
```
Read the observed state back once more:
@@ -162,6 +162,33 @@ read-only.
+## Reading deployment progress
+
+In an interactive terminal, a cyan rotating block marks the active step. The
+label stays readable while elapsed time and supporting details are quieter.
+Rolling workloads also show measured replica progress; healthchecks and drain
+waits share that same live line:
+
+```console
+▘ web rolling · waiting for healthcheck ■□ 1/2 3.2s
+```
+
+The count advances as replacements converge, including retirement of the old
+replicas. The last segment fills after replica slot assignment succeeds. It is
+a count of replica work, not a percentage of the entire deployment or an
+estimate of time remaining. Transfers and other waits use a spinner because
+their total work is not measured by the CLI.
+
+On narrow terminals, the bar gives way to the count and the label shortens to
+keep the live line from wrapping. Completed steps remain in scrollback.
+
+Piped output, CI, and `TERM=dumb` use static progress lines. Any non-empty `CI`
+value selects static output, except the exact value `CI=false`; this includes
+`CI=0` and `CI=FALSE`. Set `ONEBOX_NO_ANIMATION=1` for static output in an
+interactive terminal. `NO_COLOR`
+disables colors independently of animation. JSON and NDJSON keep their
+structured output contracts.
+
## What you now have on the host