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
44 changes: 38 additions & 6 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"log/slog"
"os"

"github.com/spf13/cobra"

Expand All @@ -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{
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
5 changes: 3 additions & 2 deletions internal/container/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 9 additions & 7 deletions internal/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package container
import (
"context"
"errors"
"io"
"time"
)

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions internal/devcontainer/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand Down
2 changes: 2 additions & 0 deletions internal/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package instance
import (
"errors"
"fmt"
"io"
"time"

"github.com/jmgilman/headjack/internal/container"
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/instance/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
231 changes: 231 additions & 0 deletions internal/spinner/spinner.go
Original file line number Diff line number Diff line change
@@ -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] + "..."
}