44 "context"
55 "errors"
66 "fmt"
7+ "strings"
8+ "sync"
79 "time"
810
911 "github.com/hardhacker/podwise-cli/internal/api"
@@ -119,8 +121,13 @@ func runProcess(cmd *cobra.Command, args []string) error {
119121 if result .Progress != nil {
120122 initialProgress = * result .Progress
121123 }
122- printProcessStatus (result , initialProgress )
123124
125+ if (prettyOutput || prettyNoPager ) && isTTY () {
126+ return runProcessPretty (ctx , client , seq , result .Status , initialProgress , startTime )
127+ }
128+
129+ // Plain (non-pretty) path.
130+ printProcessStatus (result , initialProgress )
124131 if result .Status == "done" {
125132 printProcessDoneHint (seq , time .Since (startTime ))
126133 return nil
@@ -151,7 +158,95 @@ func runProcess(cmd *cobra.Command, args []string) error {
151158 case "done" :
152159 printProcessDoneHint (seq , time .Since (startTime ))
153160 return nil
154- case "failed" :
161+ case "failed" , "not_requested" :
162+ return fmt .Errorf ("processing failed for episode %s" , episode .BuildEpisodeURL (seq ))
163+ }
164+ }
165+ return nil
166+ }
167+
168+ // runProcessPretty runs the polling loop with an animated spinner.
169+ // A goroutine redraws the status line at 100 ms so the spinner animates
170+ // continuously, while API polls happen on the normal (≥30 s) interval.
171+ func runProcessPretty (ctx context.Context , client * api.Client , seq int , initialStatus string , initialProgress float64 , startTime time.Time ) error {
172+ // Shared state between the spinner goroutine and the poll loop.
173+ var mu sync.Mutex
174+ curStatus := initialStatus
175+ curProgress := initialProgress
176+ spinIdx := 0
177+
178+ // Initial render.
179+ prettyPrintProcessStatus (& episode.ProcessResult {Status : curStatus }, curProgress , spinIdx )
180+
181+ if curStatus == "done" {
182+ prettyPrintProcessDoneHint (seq , time .Since (startTime ))
183+ return nil
184+ }
185+
186+ stopCh := make (chan struct {})
187+ var wg sync.WaitGroup
188+ wg .Add (1 )
189+
190+ // Spinner goroutine: redraws the current line at ~100 ms.
191+ go func () {
192+ defer wg .Done ()
193+ t := time .NewTicker (100 * time .Millisecond )
194+ defer t .Stop ()
195+ for {
196+ select {
197+ case <- stopCh :
198+ return
199+ case <- t .C :
200+ mu .Lock ()
201+ spinIdx ++
202+ prettyPrintProcessStatus (& episode.ProcessResult {Status : curStatus }, curProgress , spinIdx )
203+ mu .Unlock ()
204+ }
205+ }
206+ }()
207+
208+ stopSpinner := func () {
209+ select {
210+ case <- stopCh :
211+ default :
212+ close (stopCh )
213+ }
214+ wg .Wait ()
215+ }
216+
217+ deadline := time .Now ().Add (processTimeout )
218+ pollTicker := time .NewTicker (processPollInterval )
219+ defer pollTicker .Stop ()
220+
221+ for range pollTicker .C {
222+ if time .Now ().After (deadline ) {
223+ stopSpinner ()
224+ fmt .Println ()
225+ return fmt .Errorf ("timed out after %s waiting for episode %s to finish processing" , processTimeout , episode .BuildEpisodeURL (seq ))
226+ }
227+ status , err := episode .FetchStatus (ctx , client , seq )
228+ if err != nil {
229+ stopSpinner ()
230+ fmt .Println ()
231+ return err
232+ }
233+
234+ mu .Lock ()
235+ if status .Progress != nil && * status .Progress > curProgress {
236+ curProgress = * status .Progress
237+ }
238+ curStatus = status .Status
239+ mu .Unlock ()
240+
241+ switch status .Status {
242+ case "done" :
243+ stopSpinner ()
244+ prettyPrintProcessStatus (status , curProgress , 0 )
245+ prettyPrintProcessDoneHint (seq , time .Since (startTime ))
246+ return nil
247+ case "failed" , "not_requested" :
248+ stopSpinner ()
249+ prettyPrintProcessStatus (status , curProgress , 0 )
155250 return fmt .Errorf ("processing failed for episode %s" , episode .BuildEpisodeURL (seq ))
156251 }
157252 }
@@ -166,18 +261,13 @@ func printProcessStatus(r *episode.ProcessResult, maxProgress float64) {
166261 switch r .Status {
167262 case "waiting" :
168263 fmt .Printf (" [%s] → waiting episode is queued for processing\n " , ts )
169- case "processing" :
170- if maxProgress >= 0.0 {
171- fmt .Printf (" [%s] → processing %.0f%% complete\n " , ts , maxProgress )
172- }
173264 case "done" :
174265 fmt .Printf (" [%s] ✓ done processing complete (100%%)\n " , ts )
175- case "not_requested" :
176- fmt .Printf (" [%s] → not_requested transcription has not been requested yet\n " , ts )
177- case "failed" :
266+ case "failed" , "not_requested" :
178267 fmt .Printf (" [%s] ✗ failed transcription failed\n " , ts )
179268 default :
180- fmt .Printf (" [%s] ? %s\n " , ts , r .Status )
269+ // "processing" and any other transitional status are all treated as in-progress.
270+ fmt .Printf (" [%s] → processing %.0f%% complete\n " , ts , maxProgress )
181271 }
182272}
183273
@@ -195,3 +285,74 @@ func printProcessDoneHint(seq int, elapsed time.Duration) {
195285 fmt .Printf (" podwise get summary %s\n " , episodeURL )
196286 fmt .Printf (" podwise get --help to see all available commands\n " )
197287}
288+
289+ // prettySpinnerFrames are the braille spinner frames used in pretty mode.
290+ var prettySpinnerFrames = []string {"⠋" , "⠙" , "⠹" , "⠸" , "⠼" , "⠴" , "⠦" , "⠧" , "⠇" , "⠏" }
291+
292+ // prettyPrintProcessStatus overwrites the current terminal line with a styled
293+ // status. spinnerIdx is incremented by the caller on each poll to advance the
294+ // spinner. done/failed states end the line with \n so subsequent output is clean.
295+ func prettyPrintProcessStatus (r * episode.ProcessResult , maxProgress float64 , spinnerIdx int ) {
296+ const (
297+ reset = "\033 [0m"
298+ bold = "\033 [1m"
299+ dim = "\033 [2m"
300+ green = "\033 [32m"
301+ yellow = "\033 [33m"
302+ blue = "\033 [34m"
303+ red = "\033 [31m"
304+ cyan = "\033 [36m"
305+ barW = 24
306+ )
307+ spinner := prettySpinnerFrames [spinnerIdx % len (prettySpinnerFrames )]
308+
309+ buildBar := func (pct float64 ) string {
310+ n := int (pct / 100.0 * barW )
311+ if n > barW {
312+ n = barW
313+ }
314+ return strings .Repeat ("█" , n ) + strings .Repeat ("░" , barW - n )
315+ }
316+
317+ var line string
318+ switch r .Status {
319+ case "waiting" :
320+ line = fmt .Sprintf ("\r \033 [K %s%s%s %swaiting%s queued for processing" ,
321+ yellow , spinner , reset , yellow , reset )
322+ case "done" :
323+ bar := buildBar (100 )
324+ line = fmt .Sprintf ("\r \033 [K %s✓%s %sdone%s %s[%s]%s %s100%%%s\n " ,
325+ green , reset , green + bold , reset , dim , bar , reset , bold , reset )
326+ case "failed" , "not_requested" :
327+ line = fmt .Sprintf ("\r \033 [K %s✗%s %sfailed%s transcription failed\n " ,
328+ red , reset , red + bold , reset )
329+ default :
330+ // "processing" and any other transitional status are all treated as in-progress.
331+ bar := buildBar (maxProgress )
332+ line = fmt .Sprintf ("\r \033 [K %s%s%s %sprocessing%s %s[%s]%s %s%.0f%%%s" ,
333+ blue , spinner , reset , blue , reset , dim , bar , reset , bold , maxProgress , reset )
334+ }
335+ fmt .Print (line )
336+ }
337+
338+ func prettyPrintProcessDoneHint (seq int , elapsed time.Duration ) {
339+ const (
340+ reset = "\033 [0m"
341+ bold = "\033 [1m"
342+ dim = "\033 [2m"
343+ green = "\033 [32m"
344+ cyan = "\033 [36m"
345+ )
346+ episodeURL := episode .BuildEpisodeURL (seq )
347+ sep := "─────────────────────────────────────────────────────────"
348+ fmt .Printf ("\n %s%s%s\n " , dim , sep , reset )
349+ fmt .Printf (" %s✓ Processing Complete%s\n " , green + bold , reset )
350+ fmt .Printf ("%s%s%s\n " , dim , sep , reset )
351+ fmt .Printf (" %sEpisode URL:%s %s%s%s\n " , dim , reset , cyan , episodeURL , reset )
352+ fmt .Printf (" %sDuration :%s %s\n " , dim , reset , utils .FormatDuration (elapsed ))
353+ fmt .Printf ("\n " )
354+ fmt .Printf (" %sNext steps:%s\n " , bold , reset )
355+ fmt .Printf (" %spodwise get transcript%s %s\n " , cyan , reset , episodeURL )
356+ fmt .Printf (" %spodwise get summary%s %s\n " , cyan , reset , episodeURL )
357+ fmt .Printf (" %spodwise get --help%s to see all available commands\n " , cyan , reset )
358+ }
0 commit comments