feat(ui): add spinner feedback for network calls

Amolith created

Wrap all API calls with huh/spinner for immediate visual feedback.

- Add generic Spin[T] and SpinVoid helpers in internal/ui/spinner.go
- Update all commands making network calls to use the helpers
- Use proper ellipsis (…) in spinner titles

Assisted-by: Claude Sonnet 4 via Crush

Change summary

.golangci.yaml          |  1 
cmd/habit/track.go      |  6 ++++
cmd/init/apikey.go      | 17 ++------------
cmd/journal/add.go      |  5 +++
cmd/note/add.go         |  4 ++
cmd/note/delete.go      |  5 +++
cmd/note/list.go        |  4 ++
cmd/note/show.go        |  4 ++
cmd/note/update.go      |  4 ++
cmd/person/add.go       |  4 ++
cmd/person/delete.go    |  5 +++
cmd/person/list.go      |  4 ++
cmd/person/show.go      |  4 ++
cmd/person/timeline.go  |  4 ++
cmd/person/update.go    |  4 ++
cmd/ping.go             |  5 +++
cmd/task/add.go         |  4 ++
cmd/task/delete.go      |  6 ++++
cmd/task/list.go        |  4 ++
cmd/task/show.go        |  4 ++
cmd/task/update.go      |  4 ++
internal/stats/stats.go |  5 +++
internal/ui/spinner.go  | 49 +++++++++++++++++++++++++++++++++++++++++++
23 files changed, 122 insertions(+), 34 deletions(-)

Detailed changes

.golangci.yaml 🔗

@@ -151,5 +151,6 @@ linters:
       - path: internal/ui/
         linters:
           - gochecknoglobals  # Style constants are package-level vars
+          - ireturn           # Generic spinner helper uses type params
       - path: internal/config/
         text: "0o700"           # Config directory permissions are intentional

cmd/habit/track.go 🔗

@@ -63,7 +63,11 @@ func runTrack(cmd *cobra.Command, args []string) error {
 
 	req := &lunatask.TrackHabitActivityRequest{PerformedOn: date}
 
-	if _, err := apiClient.TrackHabitActivity(cmd.Context(), habit.ID, req); err != nil {
+	if err := ui.SpinVoid("Tracking habit…", func() error {
+		_, err := apiClient.TrackHabitActivity(cmd.Context(), habit.ID, req)
+
+		return err
+	}); err != nil {
 		return err
 	}
 

cmd/init/apikey.go 🔗

@@ -13,7 +13,6 @@ import (
 
 	"git.secluded.site/go-lunatask"
 	"github.com/charmbracelet/huh"
-	"github.com/charmbracelet/huh/spinner"
 	"github.com/spf13/cobra"
 
 	"git.secluded.site/lune/internal/client"
@@ -205,19 +204,9 @@ func promptForToken(out io.Writer) error {
 }
 
 func validateWithSpinner(token string) error {
-	var validationErr error
-
-	err := spinner.New().
-		Title("Verifying access token...").
-		Action(func() {
-			validationErr = validateTokenWithPing(token)
-		}).
-		Run()
-	if err != nil {
-		return err
-	}
-
-	return validationErr
+	return ui.SpinVoid("Verifying access token…", func() error {
+		return validateTokenWithPing(token)
+	})
 }
 
 func validateTokenWithPing(token string) error {

cmd/journal/add.go 🔗

@@ -9,6 +9,7 @@ import (
 	"os"
 	"strings"
 
+	"git.secluded.site/go-lunatask"
 	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/dateutil"
 	"git.secluded.site/lune/internal/ui"
@@ -60,7 +61,9 @@ func runAdd(cmd *cobra.Command, args []string) error {
 		builder.WithName(name)
 	}
 
-	entry, err := builder.Create(cmd.Context())
+	entry, err := ui.Spin("Creating journal entry…", func() (*lunatask.JournalEntry, error) {
+		return builder.Create(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

cmd/note/add.go 🔗

@@ -72,7 +72,9 @@ func runAdd(cmd *cobra.Command, args []string) error {
 
 	applySource(cmd, builder)
 
-	note, err := builder.Create(cmd.Context())
+	note, err := ui.Spin("Creating note…", func() (*lunatask.Note, error) {
+		return builder.Create(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

cmd/note/delete.go 🔗

@@ -7,6 +7,7 @@ package note
 import (
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
 	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
@@ -48,7 +49,9 @@ func runDelete(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	note, err := apiClient.DeleteNote(cmd.Context(), id)
+	note, err := ui.Spin("Deleting note…", func() (*lunatask.Note, error) {
+		return apiClient.DeleteNote(cmd.Context(), id)
+	})
 	if err != nil {
 		return err
 	}

cmd/note/list.go 🔗

@@ -47,7 +47,9 @@ func runList(cmd *cobra.Command, _ []string) error {
 
 	opts := buildListOptions(cmd)
 
-	notes, err := apiClient.ListNotes(cmd.Context(), opts)
+	notes, err := ui.Spin("Fetching notes…", func() ([]lunatask.Note, error) {
+		return apiClient.ListNotes(cmd.Context(), opts)
+	})
 	if err != nil {
 		return err
 	}

cmd/note/show.go 🔗

@@ -45,7 +45,9 @@ func runShow(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	note, err := apiClient.GetNote(cmd.Context(), id)
+	note, err := ui.Spin("Fetching note…", func() (*lunatask.Note, error) {
+		return apiClient.GetNote(cmd.Context(), id)
+	})
 	if err != nil {
 		return err
 	}

cmd/note/update.go 🔗

@@ -67,7 +67,9 @@ func runUpdate(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	note, err := builder.Update(cmd.Context())
+	note, err := ui.Spin("Updating note…", func() (*lunatask.Note, error) {
+		return builder.Update(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

cmd/person/add.go 🔗

@@ -51,7 +51,9 @@ func runAdd(cmd *cobra.Command, args []string) error {
 
 	applySource(cmd, builder)
 
-	person, err := builder.Create(cmd.Context())
+	person, err := ui.Spin("Creating person…", func() (*lunatask.Person, error) {
+		return builder.Create(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

cmd/person/delete.go 🔗

@@ -7,6 +7,7 @@ package person
 import (
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
 	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/ui"
 	"git.secluded.site/lune/internal/validate"
@@ -48,7 +49,9 @@ func runDelete(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	person, err := apiClient.DeletePerson(cmd.Context(), id)
+	person, err := ui.Spin("Deleting person…", func() (*lunatask.Person, error) {
+		return apiClient.DeletePerson(cmd.Context(), id)
+	})
 	if err != nil {
 		return err
 	}

cmd/person/list.go 🔗

@@ -45,7 +45,9 @@ func runList(cmd *cobra.Command, _ []string) error {
 
 	opts := buildListOptions(cmd)
 
-	people, err := apiClient.ListPeople(cmd.Context(), opts)
+	people, err := ui.Spin("Fetching people…", func() ([]lunatask.Person, error) {
+		return apiClient.ListPeople(cmd.Context(), opts)
+	})
 	if err != nil {
 		return err
 	}

cmd/person/show.go 🔗

@@ -44,7 +44,9 @@ func runShow(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	person, err := apiClient.GetPerson(cmd.Context(), id)
+	person, err := ui.Spin("Fetching person…", func() (*lunatask.Person, error) {
+		return apiClient.GetPerson(cmd.Context(), id)
+	})
 	if err != nil {
 		return err
 	}

cmd/person/timeline.go 🔗

@@ -54,7 +54,9 @@ func runTimeline(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	note, err := builder.Create(cmd.Context())
+	note, err := ui.Spin("Creating timeline note…", func() (*lunatask.PersonTimelineNote, error) {
+		return builder.Create(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

cmd/person/update.go 🔗

@@ -54,7 +54,9 @@ func runUpdate(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	person, err := builder.Update(cmd.Context())
+	person, err := ui.Spin("Updating person…", func() (*lunatask.Person, error) {
+		return builder.Update(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

cmd/ping.go 🔗

@@ -7,6 +7,7 @@ package cmd
 import (
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
 	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/ui"
 	"github.com/spf13/cobra"
@@ -21,7 +22,9 @@ var pingCmd = &cobra.Command{
 			return err
 		}
 
-		resp, err := c.Ping(cmd.Context())
+		resp, err := ui.Spin("Verifying token…", func() (*lunatask.PingResponse, error) {
+			return c.Ping(cmd.Context())
+		})
 		if err != nil {
 			return err
 		}

cmd/task/add.go 🔗

@@ -84,7 +84,9 @@ func runAdd(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	task, err := builder.Create(cmd.Context())
+	task, err := ui.Spin("Creating task…", func() (*lunatask.Task, error) {
+		return builder.Create(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

cmd/task/delete.go 🔗

@@ -49,7 +49,11 @@ func runDelete(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	if _, err := apiClient.DeleteTask(cmd.Context(), id); err != nil {
+	if err := ui.SpinVoid("Deleting task…", func() error {
+		_, err := apiClient.DeleteTask(cmd.Context(), id)
+
+		return err
+	}); err != nil {
 		return err
 	}
 

cmd/task/list.go 🔗

@@ -55,7 +55,9 @@ func runList(cmd *cobra.Command, _ []string) error {
 		return err
 	}
 
-	tasks, err := apiClient.ListTasks(cmd.Context(), nil)
+	tasks, err := ui.Spin("Fetching tasks…", func() ([]lunatask.Task, error) {
+		return apiClient.ListTasks(cmd.Context(), nil)
+	})
 	if err != nil {
 		return err
 	}

cmd/task/show.go 🔗

@@ -45,7 +45,9 @@ func runShow(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	task, err := apiClient.GetTask(cmd.Context(), id)
+	task, err := ui.Spin("Fetching task…", func() (*lunatask.Task, error) {
+		return apiClient.GetTask(cmd.Context(), id)
+	})
 	if err != nil {
 		return err
 	}

cmd/task/update.go 🔗

@@ -79,7 +79,9 @@ func runUpdate(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	task, err := builder.Update(cmd.Context())
+	task, err := ui.Spin("Updating task…", func() (*lunatask.Task, error) {
+		return builder.Update(cmd.Context())
+	})
 	if err != nil {
 		return err
 	}

internal/stats/stats.go 🔗

@@ -10,6 +10,7 @@ import (
 	"fmt"
 
 	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/ui"
 )
 
 // TaskCounter provides methods to count tasks by various criteria.
@@ -71,7 +72,9 @@ func (tc *TaskCounter) ensureTasks(ctx context.Context) error {
 		return nil
 	}
 
-	tasks, err := tc.client.ListTasks(ctx, nil)
+	tasks, err := ui.Spin("Fetching tasks…", func() ([]lunatask.Task, error) {
+		return tc.client.ListTasks(ctx, nil)
+	})
 	if err != nil {
 		return fmt.Errorf("listing tasks: %w", err)
 	}

internal/ui/spinner.go 🔗

@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package ui
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/huh/spinner"
+)
+
+// Spin executes fn while displaying a spinner with the given title.
+// Uses generics to preserve the return type of the wrapped function.
+func Spin[T any](title string, fn func() (T, error)) (T, error) {
+	var result T
+
+	var fnErr error
+
+	spinErr := spinner.New().
+		Title(title).
+		Action(func() {
+			result, fnErr = fn()
+		}).
+		Run()
+	if spinErr != nil {
+		return result, fmt.Errorf("spinner: %w", spinErr)
+	}
+
+	return result, fnErr
+}
+
+// SpinVoid executes fn while displaying a spinner with the given title.
+// Use for functions that only return an error.
+func SpinVoid(title string, fn func() error) error {
+	var fnErr error
+
+	spinErr := spinner.New().
+		Title(title).
+		Action(func() {
+			fnErr = fn()
+		}).
+		Run()
+	if spinErr != nil {
+		return fmt.Errorf("spinner: %w", spinErr)
+	}
+
+	return fnErr
+}