Skip to content

Commit a5fcb97

Browse files
authored
output: add Bubble Tea spinner for wait polling (PRINFRA-140) (#39)
## Description Adds an animated bubbletea spinner on stderr during `--wait` polling in `--human` mode. Replaces the plain text `Polling: status=processing (elapsed 12s)` lines with an in-place updating spinner. **What it looks like:** ``` ⠹ Processing... (12s) ``` The spinner character animates, elapsed time ticks up, and status text updates when the API reports a transition (e.g., `Pending...` → `Processing...`). On completion, the spinner line is cleared before the result renders to stdout. **Colors:** Spinner character uses HeyGen brand purple (`#7559FF` from design tokens). Elapsed time uses dim gray (matching `hintStyle` in HumanFormatter). **Behavior by mode:** - `--human` + terminal stderr → animated spinner - `--human` + piped stderr → plain text fallback (bubbletea can't render in-place without a TTY) - JSON mode (default) → silent stderr (unchanged) **TTY detection:** `isTerminal()` checks if stderr is a real terminal via `term.IsTerminal`. Tests use `bytes.Buffer` (non-TTY), so they automatically get the plain text fallback with no test changes needed. **Lifecycle:** Spinner starts before `ExecuteAndPoll`, stops immediately after it returns (before any error handling or result rendering). `Stop()` uses `sync.Once` for safe double-call. Quit clears the terminal line via `\r\033[2K`. Stacked on PR #38 (video download). Linear: PRINFRA-140 ## Testing 3 spinner model unit tests: view includes status and elapsed time, quit clears line, status humanization (`in_progress` → `In progress...`). 1 builder integration test: `--wait --human` with non-TTY stderr verifies plain text fallback (progress lines emitted, human-formatted stdout). All tests use `httptest.Server` — no real API calls.
1 parent 493d778 commit a5fcb97

6 files changed

Lines changed: 298 additions & 6 deletions

File tree

cmd/heygen/builder.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
1616
"github.com/heygen-com/heygen-cli/internal/output"
1717
"github.com/spf13/cobra"
18+
"golang.org/x/term"
1819
)
1920

2021
// buildCobraCommand creates a Cobra command from a command.Spec.
@@ -65,18 +66,29 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
6566
}
6667
// Only emit progress in human mode. JSON mode keeps stderr
6768
// clean for machine consumption (structured errors only).
69+
var spinner *output.PollSpinner
6870
if _, ok := ctx.formatter.(*output.HumanFormatter); ok {
69-
var lastStatus string
70-
opts.OnStatus = func(status string, elapsed time.Duration) {
71-
if status == lastStatus {
72-
return
71+
if isTerminal(cmd.ErrOrStderr()) {
72+
spinner = output.StartPollSpinner(cmd.ErrOrStderr())
73+
opts.OnStatus = func(status string, elapsed time.Duration) {
74+
spinner.UpdateStatus(status, elapsed)
75+
}
76+
} else {
77+
var lastStatus string
78+
opts.OnStatus = func(status string, elapsed time.Duration) {
79+
if status == lastStatus {
80+
return
81+
}
82+
fmt.Fprintf(cmd.ErrOrStderr(), "Polling: status=%s (elapsed %s)\n", status, elapsed.Round(time.Second))
83+
lastStatus = status
7384
}
74-
fmt.Fprintf(cmd.ErrOrStderr(), "Polling: status=%s (elapsed %s)\n", status, elapsed.Round(time.Second))
75-
lastStatus = status
7685
}
7786
}
7887

7988
result, err := ctx.client.ExecuteAndPoll(cmd.Context(), &pollSpec, inv, opts)
89+
if spinner != nil {
90+
spinner.Stop()
91+
}
8092
if err != nil {
8193
var timeoutErr *client.ErrPollTimeout
8294
if errors.As(err, &timeoutErr) {
@@ -163,6 +175,14 @@ func buildUseLine(spec *command.Spec) string {
163175
return strings.Join(parts, " ")
164176
}
165177

178+
func isTerminal(w io.Writer) bool {
179+
f, ok := w.(*os.File)
180+
if !ok {
181+
return false
182+
}
183+
return term.IsTerminal(int(f.Fd()))
184+
}
185+
166186
// registerFlag adds a typed flag to the Cobra command based on the FlagSpec.
167187
func registerFlag(cmd *cobra.Command, flag command.FlagSpec) {
168188
helpText := flag.Help

cmd/heygen/builder_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,52 @@ func TestGenBuilder_VideoCreate_Wait_Success(t *testing.T) {
166166
}
167167
}
168168

169+
func TestGenBuilder_VideoCreate_Wait_Human_NonTTYFallback(t *testing.T) {
170+
var statusCalls int
171+
srv := setupTestServer(t, map[string]testHandler{
172+
"POST /v3/videos": {
173+
StatusCode: 200,
174+
Body: `{"data":{"video_id":"vid_123"}}`,
175+
},
176+
"GET /v3/videos/vid_123": {
177+
StatusCode: 200,
178+
ValidateRequest: func(t *testing.T, r *http.Request) {
179+
t.Helper()
180+
statusCalls++
181+
},
182+
Body: `{"data":{"video_id":"vid_123","status":"processing"}}`,
183+
},
184+
})
185+
defer srv.Close()
186+
187+
originalHandler := srv.Config.Handler
188+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189+
if r.Method == http.MethodGet && r.URL.Path == "/v3/videos/vid_123" {
190+
statusCalls++
191+
w.WriteHeader(http.StatusOK)
192+
if statusCalls < 2 {
193+
_, _ = w.Write([]byte(`{"data":{"video_id":"vid_123","status":"processing"}}`))
194+
return
195+
}
196+
_, _ = w.Write([]byte(`{"data":{"video_id":"vid_123","status":"completed","video_url":"https://cdn.test/video.mp4"}}`))
197+
return
198+
}
199+
originalHandler.ServeHTTP(w, r)
200+
})
201+
202+
res := runGenCommand(t, srv.URL, "test-key", videoCreateWaitSpec, "create", "--wait", "--human")
203+
204+
if res.ExitCode != 0 {
205+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
206+
}
207+
if !strings.Contains(res.Stderr, "Polling: status=processing") {
208+
t.Fatalf("stderr = %s, want plain-text non-TTY progress", res.Stderr)
209+
}
210+
if !strings.Contains(res.Stdout, "Status:") {
211+
t.Fatalf("stdout = %s, want human-formatted output", res.Stdout)
212+
}
213+
}
214+
169215
func TestGenBuilder_VideoCreate_Wait_Failure(t *testing.T) {
170216
srv := setupTestServer(t, map[string]testHandler{
171217
"POST /v3/videos": {

go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ require (
1212

1313
require (
1414
github.com/BurntSushi/toml v1.4.0
15+
github.com/charmbracelet/bubbles v0.20.0
16+
github.com/charmbracelet/bubbletea v1.2.4
1517
github.com/charmbracelet/lipgloss v1.1.0
1618
golang.org/x/term v0.30.0
1719
)
@@ -22,15 +24,19 @@ require (
2224
github.com/charmbracelet/x/ansi v0.8.0 // indirect
2325
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2426
github.com/charmbracelet/x/term v0.2.1 // indirect
27+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2528
github.com/go-openapi/jsonpointer v0.21.0 // indirect
2629
github.com/go-openapi/swag v0.23.0 // indirect
2730
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2831
github.com/josharian/intern v1.0.0 // indirect
2932
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
3033
github.com/mailru/easyjson v0.7.7 // indirect
3134
github.com/mattn/go-isatty v0.0.20 // indirect
35+
github.com/mattn/go-localereader v0.0.1 // indirect
3236
github.com/mattn/go-runewidth v0.0.16 // indirect
3337
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
38+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
39+
github.com/muesli/cancelreader v0.2.2 // indirect
3440
github.com/muesli/termenv v0.16.0 // indirect
3541
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
3642
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
@@ -39,5 +45,7 @@ require (
3945
github.com/spf13/pflag v1.0.9 // indirect
4046
github.com/woodsbury/decimal128 v1.3.0 // indirect
4147
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
48+
golang.org/x/sync v0.9.0 // indirect
4249
golang.org/x/sys v0.31.0 // indirect
50+
golang.org/x/text v0.3.8 // indirect
4351
)

go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0
22
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
33
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
44
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5+
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
6+
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
7+
github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
8+
github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
59
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
610
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
711
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -15,6 +19,8 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE
1519
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1620
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1721
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
22+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
23+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
1824
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
1925
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
2026
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -41,10 +47,16 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
4147
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
4248
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
4349
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
50+
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
51+
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
4452
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
4553
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
4654
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
4755
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
56+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
57+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
58+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
59+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
4860
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
4961
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
5062
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
@@ -76,11 +88,16 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
7688
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
7789
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
7890
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
91+
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
92+
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
93+
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7994
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
8095
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
8196
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
8297
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
8398
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
99+
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
100+
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
84101
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
85102
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
86103
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

internal/output/spinner.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package output
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
"sync"
8+
"time"
9+
10+
spin "github.com/charmbracelet/bubbles/spinner"
11+
tea "github.com/charmbracelet/bubbletea"
12+
"github.com/charmbracelet/lipgloss"
13+
)
14+
15+
var spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7559FF"))
16+
17+
const clearLine = "\r\033[2K"
18+
19+
type statusMsg struct {
20+
status string
21+
elapsed time.Duration
22+
}
23+
24+
type quitMsg struct{}
25+
26+
// PollSpinner renders interactive stderr progress for --wait polling.
27+
type PollSpinner struct {
28+
program *tea.Program
29+
done chan struct{}
30+
once sync.Once
31+
}
32+
33+
// StartPollSpinner starts a spinner program that writes to w.
34+
func StartPollSpinner(w io.Writer) *PollSpinner {
35+
model := newSpinnerModel()
36+
program := tea.NewProgram(
37+
model,
38+
tea.WithOutput(w),
39+
tea.WithInput(nil),
40+
tea.WithoutSignalHandler(),
41+
)
42+
43+
s := &PollSpinner{
44+
program: program,
45+
done: make(chan struct{}),
46+
}
47+
48+
go func() {
49+
defer close(s.done)
50+
_, _ = program.Run()
51+
}()
52+
53+
return s
54+
}
55+
56+
// UpdateStatus updates the rendered status and elapsed time.
57+
func (s *PollSpinner) UpdateStatus(status string, elapsed time.Duration) {
58+
if s == nil || s.program == nil {
59+
return
60+
}
61+
s.program.Send(statusMsg{
62+
status: status,
63+
elapsed: elapsed,
64+
})
65+
}
66+
67+
// Stop clears the spinner line and waits for the program to exit.
68+
func (s *PollSpinner) Stop() {
69+
if s == nil || s.program == nil {
70+
return
71+
}
72+
s.once.Do(func() {
73+
s.program.Send(quitMsg{})
74+
<-s.done
75+
})
76+
}
77+
78+
type spinnerModel struct {
79+
spinner spin.Model
80+
status string
81+
elapsed time.Duration
82+
quitting bool
83+
}
84+
85+
func newSpinnerModel() spinnerModel {
86+
m := spinnerModel{
87+
spinner: spin.New(
88+
spin.WithSpinner(spin.MiniDot),
89+
spin.WithStyle(spinnerStyle),
90+
),
91+
status: "waiting",
92+
}
93+
return m
94+
}
95+
96+
func (m spinnerModel) Init() tea.Cmd {
97+
return m.spinner.Tick
98+
}
99+
100+
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101+
switch msg := msg.(type) {
102+
case statusMsg:
103+
m.status = msg.status
104+
m.elapsed = msg.elapsed
105+
return m, nil
106+
case quitMsg:
107+
m.quitting = true
108+
return m, tea.Quit
109+
default:
110+
var cmd tea.Cmd
111+
m.spinner, cmd = m.spinner.Update(msg)
112+
return m, cmd
113+
}
114+
}
115+
116+
func (m spinnerModel) View() string {
117+
if m.quitting {
118+
return clearLine
119+
}
120+
121+
return fmt.Sprintf(
122+
"\r%s %s %s",
123+
m.spinner.View(),
124+
renderSpinnerStatus(m.status),
125+
hintStyle.Render(formatSpinnerElapsed(m.elapsed)),
126+
)
127+
}
128+
129+
func renderSpinnerStatus(status string) string {
130+
text := strings.TrimSpace(status)
131+
if text == "" {
132+
text = "waiting"
133+
}
134+
135+
text = strings.ReplaceAll(text, "_", " ")
136+
text = strings.ReplaceAll(text, "-", " ")
137+
text = strings.ToLower(text)
138+
if len(text) > 0 {
139+
text = strings.ToUpper(text[:1]) + text[1:]
140+
}
141+
142+
return text + "..."
143+
}
144+
145+
func formatSpinnerElapsed(elapsed time.Duration) string {
146+
if elapsed < 0 {
147+
elapsed = 0
148+
}
149+
return fmt.Sprintf("(%s)", elapsed.Round(time.Second))
150+
}

0 commit comments

Comments
 (0)