Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
701 changes: 701 additions & 0 deletions docs/superpowers/plans/2026-07-04-tui-busy-spinner.md

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions docs/superpowers/specs/2026-07-04-tui-busy-spinner-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# TUI busy spinner for slow transitions

## Problem

Creating a worktree from the TUI can take several seconds. Since commit
`37f0884`, every creation path freshens its source with a network `fetch`
before branching, and the tracking path fetches a remote branch outright.
Per-project hooks run after creation and can be slow too. During this whole
window the dashboard shows the frozen, completed form with no feedback, so the
UI looks hung.

The same is true of two other slow transitions:

- The remote-branch picker (`r`) fetches all remotes. It shows a static
`"Fetching remote branches…"` string that never animates, so it also reads as
stuck.
- The clone action (`c`) does network work and only sets a `statusMsg`.

## Goal

Give a clear in-progress indication during these slow transitions so the UI
never looks hung. A single animated spinner, shown on a dedicated centered busy
screen, covering all three transitions with one shared mechanism.

Non-goals: phased/step-by-step progress (fetch vs. create vs. hooks),
cancellation of in-flight git operations, and progress percentages. A generic
animated spinner is enough to solve the "looks hung" problem; staged progress
can be a later increment if ever justified.

## Architecture: one shared busy state

The slow work already runs off the UI goroutine — `form.go:submit()`,
`cloneAction`, and the remote-fetch command each return a `tea.Cmd` that runs to
completion and emits a result message. The gap is purely presentational: nothing
is shown while the command is in flight.

Add a single reusable busy indicator to the top-level `Model` rather than three
ad-hoc ones:

```go
// in Model
spinner spinner.Model // charm.land/bubbles/v2/spinner
busy string // non-empty label ⇒ busy screen is active
```

- `busy == ""` → normal rendering (list / form / pickers), unchanged.
- `busy != ""` → `View()` short-circuits to a centered `"<spinner> <busy>"`,
overriding whatever `m.screen` is.

The underlying `m.screen` is **never changed** by the busy state. When the result
message clears `busy`, the correct screen renders again automatically — the list
after a create/clone, or the remote picker showing its now-loaded list. This
keeps the "which screen do I return to?" logic trivial: there is nothing to
remember.

`busy` is a plain label string rather than a bool so the same screen can say
`"Creating worktree…"`, `"Cloning myapp…"`, or `"Fetching remote branches…"`.

## Data flow: set on dispatch, clear on result

| Trigger | Sets `busy` to | Cleared by |
|---|---|---|
| Form submit (`submit()`) | `"Creating worktree…"` | `worktreeCreatedMsg` / `errMsg` |
| Clone action (`c`) | `"Cloning <project>…"` | `cloneDoneMsg` / `errMsg` |
| Remote picker open (`r`) | `"Fetching remote branches…"` | `remoteBranchesMsg` |

Setting `busy` and dispatching the slow command happen together in the same
`Update` branch. The command is unchanged; only the surrounding state is set.

### Spinner animation

The spinner animates on its own `spinner.TickMsg` loop, independent of the
existing 3-second `tickMsg` refresh loop (which keeps running harmlessly behind
the busy screen — a background `itemsMsg` refresh just updates the hidden list).

- Whenever `busy` is set, `tea.Batch` in `m.spinner.Tick` alongside the slow
command.
- The `spinner.TickMsg` handler advances the frame and re-issues the spinner
tick **only while `busy != ""`**, so the animation loop stops cleanly once the
result arrives and `busy` is cleared. This avoids a runaway tick loop after the
operation completes.

### Removing the duplicate loading path

The remote picker's own `loading bool` and its static
`"Fetching remote branches…"` `View()` branch are **removed** and folded into the
shared busy state — one loading mechanism, not two. The picker keeps its
`errText` / empty-list / list branches. On `remoteBranchesMsg`:

- success → `busy` clears, `screenRemotePicker` renders the list (or the "no
remote branches found" empty state);
- error → `busy` clears and the picker's `errText` shows the error, exactly as
today.

On any `errMsg` for create/clone, `busy` clears and the existing `statusMsg`
renders the error in the status line, as it does today. On success, the existing
`statusMsg` (`"Created myapp/feat"`, `"Cloned myapp"`) shows once the busy screen
is gone.

### Keys while busy

While `busy != ""`, all input is swallowed except the global quit binding
(`q` / `Ctrl-C`), which quits the app. The git operation itself is not
cancellable mid-flight, so there is no "cancel" affordance — `Esc` does nothing
until the result message arrives. This is enforced by an early check at the top
of `Update`'s key handling: if `busy != ""`, only quit is honored.

## View

When `busy != ""`, `View()` returns a centered single line:

```


◐ Creating worktree…


```

Centering reuses the model's known `width`/`height`. The exact vertical/
horizontal centering follows whatever the existing views already do for layout;
if there is no shared centering helper, a small local one is fine (it is only
used here).

## Testing

Following the repo's table-driven `internal/tui/model_test.go` style. No new
integration tests — this is pure TUI state, and `spinner.Model` is deterministic
under an injected `TickMsg`.

- **Transitions set busy**: submitting the form, clone, and `r` each set `busy`
to the expected label and batch a spinner tick.
- **Results clear busy**: `worktreeCreatedMsg`, `cloneDoneMsg`,
`remoteBranchesMsg` (success and error), and `errMsg` each clear `busy`.
- **View**: `View()` renders the centered label when `busy != ""` and the normal
screen when `busy == ""`.
- **Keys while busy**: a non-quit key press while `busy != ""` is a no-op; the
quit binding still quits.
- **Tick loop stops**: a `spinner.TickMsg` while `busy == ""` does not re-issue a
tick.

## Files touched

- `internal/tui/model.go` — `spinner`/`busy` fields; `Update` branches to set on
dispatch and clear on result; `View` short-circuit; key-swallow while busy;
spinner tick handling.
- `internal/tui/actions.go` — set `busy` in `cloneAction` dispatch; set `busy`
when opening the remote picker.
- `internal/tui/form.go` — set `busy` when `submit()` is dispatched.
- `internal/tui/remote_picker.go` — remove `loading` and its `View()` branch.
- `internal/tui/model_test.go` (and picker/actions tests as needed) — cover the
cases above.
5 changes: 4 additions & 1 deletion internal/tui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ func (m Model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) {
m.form, cmd = m.form.Update(msg)

if m.form.completed() {
return m, m.form.submit()
// Reassign m (do not shadow it with :=) so the linter stays quiet.
var submitCmd tea.Cmd
m, submitCmd = m.enterBusy("Creating worktree…", m.form.submit())
return m, submitCmd
}

return m, cmd
Expand Down
18 changes: 18 additions & 0 deletions internal/tui/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"

"github.com/xico42/codeherd/internal/config"
Expand Down Expand Up @@ -325,3 +326,20 @@ func toListItems(items []Item) []list.Item {
}
return result
}

func TestUpdateForm_nonCompletedDoesNotSetBusy(t *testing.T) {
ctx := formContext{project: "api", baseBranch: "develop"}
cfg := &config.Config{
Projects: map[string]config.ProjectConfig{"api": {}},
Agents: map[string]config.AgentConfig{"claude": {Cmd: "claude"}},
}
m := Model{screen: screenForm, spinner: spinner.New(), cfg: cfg}
m.form = newFormModel(ctx, cfg, nil)
m.form.Init()

updated, _ := m.updateForm(tea.KeyPressMsg(tea.Key{Code: 'a', Text: "a"}))

if updated.(Model).busy != "" {
t.Errorf("busy = %q, want empty for a non-completed form", updated.(Model).busy)
}
}
70 changes: 67 additions & 3 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/term"
Expand Down Expand Up @@ -79,6 +80,15 @@ type Model struct {
// Status message for async operations.
statusMsg string

// busy is a non-empty label while a blocking git operation is in
// flight. When set, View renders a centered spinner + label,
// overriding m.screen (which is left unchanged). Cleared by the
// operation's result message.
busy string

// spinner animates the busy screen. It only ticks while busy != "".
spinner spinner.Model

// Form sub-model.
form *formModel

Expand Down Expand Up @@ -117,12 +127,14 @@ func NewModel(
keys := defaultKeyMap()
l := newList(nil)
h := help.New()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))

m := Model{
screen: screenList,
list: l,
keys: keys,
help: h,
spinner: sp,
cfg: cfg,
wtSvc: wtSvc,
sesSvc: sesSvc,
Expand All @@ -149,6 +161,14 @@ func (m Model) syncProfileKeyEnabled() Model {
return m
}

// enterBusy sets the busy label and batches the given command with a spinner
// tick, so the spinner animates while cmd runs. m.screen is left unchanged;
// View short-circuits to the busy screen while busy != "".
func (m Model) enterBusy(label string, cmd tea.Cmd) (Model, tea.Cmd) {
m.busy = label
return m, tea.Batch(cmd, m.spinner.Tick)
}

func newList(items []list.Item) list.Model {
if items == nil {
items = []list.Item{}
Expand All @@ -171,6 +191,17 @@ func (m Model) Init() tea.Cmd {
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// While a blocking operation is in flight, swallow all input except
// quit. The screen underneath is hidden by the busy view, and the git
// operation cannot be cancelled mid-flight, so other keys must not
// mutate state that the pending result message will rely on.
if kp, ok := msg.(tea.KeyPressMsg); ok && m.busy != "" {
if key.Matches(kp, m.keys.Quit) {
return m, tea.Quit
}
return m, nil
}

switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
Expand All @@ -186,6 +217,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tickMsg:
return m, tea.Batch(m.refreshCmd(), tickCmd())

case spinner.TickMsg:
// Only advance (and thus re-schedule) while busy, so the tick
// loop stops cleanly once the operation completes.
if m.busy == "" {
return m, nil
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd

case itemsMsg:
items := make([]list.Item, len(msg))
for i, item := range msg {
Expand Down Expand Up @@ -216,6 +257,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd

case errMsg:
m.busy = ""
if msg.err != nil {
m.statusMsg = msg.err.Error()
}
Expand All @@ -229,10 +271,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit

case cloneDoneMsg:
m.busy = ""
m.statusMsg = fmt.Sprintf("Cloned %s", msg.project)
return m, m.refreshCmd()

case worktreeCreatedMsg:
m.busy = ""
m.statusMsg = fmt.Sprintf("Created %s/%s", msg.project, msg.branch)
m.screen = screenList
m.form = nil
Expand All @@ -242,10 +286,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.refreshCmd()

case remoteBranchesMsg:
m.busy = ""
if m.remotePicker != nil && m.remotePicker.project == msg.project {
if msg.err != nil {
m.remotePicker.errText = msg.err.Error()
m.remotePicker.loading = false
} else {
m.remotePicker.setBranches(msg.branches)
}
Expand Down Expand Up @@ -287,7 +331,15 @@ func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.shellAction()

case key.Matches(msg, m.keys.Clone):
return m, m.cloneAction()
cmd := m.cloneAction()
if cmd == nil {
return m, nil
}
// cloneAction returned non-nil ⇒ selection is a valid
// uncloned project, so selectedItem() is non-nil.
sel := m.selectedItem()
m, cmd = m.enterBusy(fmt.Sprintf("Cloning %s…", sel.Project), cmd)
return m, cmd

case key.Matches(msg, m.keys.New):
return m.showForm()
Expand Down Expand Up @@ -316,6 +368,17 @@ func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) {
}

func (m Model) View() tea.View {
if m.busy != "" {
body := lipgloss.Place(
m.width, m.height,
lipgloss.Center, lipgloss.Center,
m.spinner.View()+" "+m.busy,
)
v := tea.NewView(body)
v.AltScreen = true
return v
}

var content string
switch m.screen {
case screenForm:
Expand Down Expand Up @@ -582,7 +645,8 @@ func (m Model) startRemotePicker() (tea.Model, tea.Cmd) {
}
m.remotePicker = newRemotePicker(sel.Project, m.cfg, m.tmuxClient)
m.screen = screenRemotePicker
return m, m.fetchRemoteBranchesCmd(sel.Project)
m, cmd := m.enterBusy("Fetching remote branches…", m.fetchRemoteBranchesCmd(sel.Project))
return m, cmd
}

// fetchRemoteBranchesCmd fetches all remotes (best-effort) and lists the
Expand Down
Loading
Loading