From 102593829f1ff8b54e565c3ffd43f95dbb087c8b Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 5 Jan 2026 13:31:59 -0800 Subject: [PATCH] feat(cli): add progress spinner during instance creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current behavior: When creating an instance for the first time, the CLI shows no output while images are pulled or devcontainers are built, making it appear frozen during long operations. New behavior: - Default mode: Shows a ticker-style spinner that displays the latest log line from docker/devcontainer CLI, updating in place without polluting the terminal buffer. - Verbose mode (-v): Streams full CLI output directly to stderr. The spinner uses charmbracelet/bubbletea with an io.Pipe to capture subprocess stderr and display progress in real-time. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/run.go | 44 +++++- internal/container/common.go | 5 +- internal/container/container.go | 16 ++- internal/devcontainer/runtime.go | 5 +- internal/instance/instance.go | 2 + internal/instance/manager.go | 1 + internal/spinner/spinner.go | 231 +++++++++++++++++++++++++++++++ 7 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 internal/spinner/spinner.go diff --git a/internal/cmd/run.go b/internal/cmd/run.go index f643310..4cb267e 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log/slog" + "os" "github.com/spf13/cobra" @@ -12,6 +13,7 @@ import ( "github.com/jmgilman/headjack/internal/instance" "github.com/jmgilman/headjack/internal/prompt" "github.com/jmgilman/headjack/internal/slogger" + "github.com/jmgilman/headjack/internal/spinner" ) var runCmd = &cobra.Command{ @@ -104,6 +106,8 @@ func runRunCmd(cmd *cobra.Command, args []string) error { // If the instance exists but is stopped, it restarts the container. // If imageExplicit is false and a devcontainer.json exists, devcontainer mode is used. func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, branch string, flags *runFlags) (*instance.Instance, error) { + log := slogger.L(cmd.Context()) + // Try to get existing instance inst, err := mgr.GetByBranch(cmd.Context(), repoPath, branch) if err == nil { @@ -112,7 +116,7 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br if startErr := mgr.Start(cmd.Context(), inst.ID); startErr != nil { return nil, fmt.Errorf("start stopped instance: %w", startErr) } - slogger.L(cmd.Context()).Debug("restarted stopped instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch)) + log.Debug("restarted stopped instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch)) // Refresh the instance to get updated status inst, err = mgr.GetByBranch(cmd.Context(), repoPath, branch) if err != nil { @@ -131,13 +135,41 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br return nil, err } - // Create new instance - inst, err = mgr.Create(cmd.Context(), repoPath, &createCfg) - if err != nil { - return nil, fmt.Errorf("create instance: %w", err) + // Determine output mode from verbosity + // Info level is enabled when -v is passed (verbosity >= 1) + verbose := log.Enabled(cmd.Context(), slog.LevelInfo) + + if verbose { + // Verbose mode: stream CLI output directly to stderr + createCfg.Stderr = os.Stderr + inst, err = mgr.Create(cmd.Context(), repoPath, &createCfg) + if err != nil { + return nil, fmt.Errorf("create instance: %w", err) + } + } else { + // Default mode: show ticker spinner with progress from CLI + spin := spinner.New(os.Stderr) + createCfg.Stderr = spin.Writer() + + // Run creation in a goroutine while spinner displays + var createErr error + go func() { + inst, createErr = mgr.Create(cmd.Context(), repoPath, &createCfg) + spin.Stop() + }() + + // Start blocks until Stop() is called + if spinErr := spin.Start(); spinErr != nil { + // Spinner error is non-fatal, continue if we got an instance + log.Debug("spinner error", slog.String("error", spinErr.Error())) + } + + if createErr != nil { + return nil, fmt.Errorf("create instance: %w", createErr) + } } - slogger.L(cmd.Context()).Debug("created new instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch)) + log.Debug("created new instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch)) return inst, nil } diff --git a/internal/container/common.go b/internal/container/common.go index 780e784..5b8ef5b 100644 --- a/internal/container/common.go +++ b/internal/container/common.go @@ -56,8 +56,9 @@ func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, erro args := buildRunArgs(cfg) result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, + Name: r.binaryName, + Args: args, + Stderr: cfg.Stderr, // Stream stderr if writer provided (for progress output) }) if err != nil { stderr := string(result.Stderr) diff --git a/internal/container/container.go b/internal/container/container.go index 483dd40..5ef2d13 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -4,6 +4,7 @@ package container import ( "context" "errors" + "io" "time" ) @@ -56,13 +57,14 @@ type Mount struct { // RunConfig configures container creation. type RunConfig struct { - Name string // Container name (required) - Image string // OCI image reference (required for vanilla runtimes) - Mounts []Mount // Volume mounts - Env []string // Environment variables (KEY=VALUE format) - Init string // Init command to run as PID 1 (default: "sleep infinity") - Flags []string // Runtime-specific flags (e.g., "--systemd=always" for Podman) - WorkspaceFolder string // For devcontainer: path to folder with devcontainer.json + Name string // Container name (required) + Image string // OCI image reference (required for vanilla runtimes) + Mounts []Mount // Volume mounts + Env []string // Environment variables (KEY=VALUE format) + Init string // Init command to run as PID 1 (default: "sleep infinity") + Flags []string // Runtime-specific flags (e.g., "--systemd=always" for Podman) + WorkspaceFolder string // For devcontainer: path to folder with devcontainer.json + Stderr io.Writer // Optional: stream stderr during container creation (for progress output) } // ExecConfig configures command execution in a container. diff --git a/internal/devcontainer/runtime.go b/internal/devcontainer/runtime.go index 942ac88..113bc36 100644 --- a/internal/devcontainer/runtime.go +++ b/internal/devcontainer/runtime.go @@ -62,8 +62,9 @@ func (r *Runtime) Run(ctx context.Context, cfg *container.RunConfig) (*container args = append(args, cfg.Flags...) result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.cliPath, - Args: args, + Name: r.cliPath, + Args: args, + Stderr: cfg.Stderr, // Stream stderr if writer provided (for progress output) }) if err != nil { stderr := "" diff --git a/internal/instance/instance.go b/internal/instance/instance.go index 853dd42..a386c66 100644 --- a/internal/instance/instance.go +++ b/internal/instance/instance.go @@ -4,6 +4,7 @@ package instance import ( "errors" "fmt" + "io" "time" "github.com/jmgilman/headjack/internal/container" @@ -64,6 +65,7 @@ type CreateConfig struct { WorkspaceFolder string // Path to folder with devcontainer.json (devcontainer mode) Runtime container.Runtime // Optional runtime override (for devcontainer) RuntimeFlags []string // Additional flags to pass to the container runtime + Stderr io.Writer // Optional: stream stderr during creation (for progress output) } // AttachConfig configures instance attachment. diff --git a/internal/instance/manager.go b/internal/instance/manager.go index c066aa0..cd847b5 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -195,6 +195,7 @@ func (m *Manager) Create(ctx context.Context, repoPath string, cfg *CreateConfig // Build container run config based on mode (devcontainer vs vanilla) runCfg := m.buildRunConfig(cfg, containerName, worktreePath) + runCfg.Stderr = cfg.Stderr // Pass through stderr writer for progress output // Create container log.Debug("creating container", slog.String("name", containerName), slog.String("image", cfg.Image)) diff --git a/internal/spinner/spinner.go b/internal/spinner/spinner.go new file mode 100644 index 0000000..e6d506a --- /dev/null +++ b/internal/spinner/spinner.go @@ -0,0 +1,231 @@ +// Package spinner provides a terminal spinner with ticker-style status display. +// It shows a spinning indicator alongside the latest log line from a subprocess, +// updating in place without polluting the terminal buffer. +package spinner + +import ( + "bufio" + "io" + "os" + "strings" + "sync" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +// Spinner displays a spinner with ticker-style status updates. +// Output from a subprocess can be piped through Writer(), and the latest +// line will be displayed next to the spinner. +type Spinner struct { + program *tea.Program + reader *io.PipeReader + writer *io.PipeWriter + lineCh chan string + done chan struct{} + wg sync.WaitGroup + output io.Writer +} + +// New creates a new Spinner that writes to the given output (typically os.Stderr). +// If output is nil, os.Stderr is used. +func New(output io.Writer) *Spinner { + if output == nil { + output = os.Stderr + } + + reader, writer := io.Pipe() + return &Spinner{ + reader: reader, + writer: writer, + lineCh: make(chan string, 100), // Buffer to avoid blocking the pipe reader + done: make(chan struct{}), + output: output, + } +} + +// Writer returns the io.Writer that should be passed to subprocesses. +// Lines written here will appear in the spinner's status display. +func (s *Spinner) Writer() io.Writer { + return s.writer +} + +// Start begins the spinner display. This blocks until Stop() is called. +// Call this in a goroutine if you need to do work while the spinner runs. +func (s *Spinner) Start() error { + // Start the line reader goroutine + s.wg.Add(1) + go s.readLines() + + // Get terminal width for truncation + width := 80 // default + if fd := int(os.Stderr.Fd()); term.IsTerminal(fd) { + if w, _, err := term.GetSize(fd); err == nil && w > 0 { + width = w + } + } + + // Create the bubbletea model + m := newModel(s.lineCh, width) + + // Create and run the program + s.program = tea.NewProgram(m, + tea.WithOutput(s.output), + tea.WithoutSignalHandler(), // Let parent handle signals + ) + + _, err := s.program.Run() + + // Wait for line reader to finish + s.wg.Wait() + + return err +} + +// Stop stops the spinner and cleans up resources. +// The spinner line is cleared from the terminal. +func (s *Spinner) Stop() { + // Close the writer to signal EOF to the line reader + _ = s.writer.Close() + + // Signal done and close line channel + close(s.done) + + // Tell the program to quit + if s.program != nil { + s.program.Quit() + } +} + +// readLines reads lines from the pipe and sends them to the model. +func (s *Spinner) readLines() { + defer s.wg.Done() + defer s.reader.Close() + + scanner := bufio.NewScanner(s.reader) + for scanner.Scan() { + line := scanner.Text() + // Skip empty lines + if strings.TrimSpace(line) == "" { + continue + } + select { + case s.lineCh <- line: + case <-s.done: + return + } + } +} + +// model is the bubbletea model for the spinner. +type model struct { + spinner spinner.Model + statusLine string + width int + lineCh <-chan string + quitting bool +} + +// lineMsg is sent when a new line is received from the pipe. +type lineMsg string + +// newModel creates a new spinner model. +func newModel(lineCh <-chan string, width int) model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + return model{ + spinner: s, + statusLine: "", + width: width, + lineCh: lineCh, + } +} + +// Init implements tea.Model. +// +//nolint:gocritic // hugeParam: tea.Model interface requires value receiver +func (m model) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + waitForLine(m.lineCh), + ) +} + +// Update implements tea.Model. +// +//nolint:gocritic // hugeParam: tea.Model interface requires value receiver +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // Allow ctrl+c to quit + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + + case lineMsg: + m.statusLine = string(msg) + return m, waitForLine(m.lineCh) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case tea.QuitMsg: + m.quitting = true + return m, nil + } + + return m, nil +} + +// View implements tea.Model. +// +//nolint:gocritic // hugeParam: tea.Model interface requires value receiver +func (m model) View() string { + if m.quitting { + return "" // Clear the line on exit + } + + // Calculate available width for status line + // Spinner is typically 2 chars + 1 space + spinnerWidth := 3 + maxLineWidth := m.width - spinnerWidth + if maxLineWidth < 10 { + maxLineWidth = 10 + } + + line := truncate(m.statusLine, maxLineWidth) + return m.spinner.View() + " " + line +} + +// waitForLine returns a command that waits for the next line from the channel. +func waitForLine(lineCh <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-lineCh + if !ok { + return tea.Quit() + } + return lineMsg(line) + } +} + +// truncate shortens a string to fit within maxWidth. +// If truncated, it adds "..." at the end. +func truncate(s string, maxWidth int) string { + if maxWidth <= 3 { + return "" + } + if len(s) <= maxWidth { + return s + } + return s[:maxWidth-3] + "..." +}