Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
184 changes: 135 additions & 49 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import (
"claude-squad/keys"
"claude-squad/log"
"claude-squad/session"
"claude-squad/session/git"
"claude-squad/ui"
"claude-squad/ui/overlay"
"context"
"fmt"
"os"
"strings"
"sync"
"time"

"github.com/charmbracelet/bubbles/spinner"
Expand Down Expand Up @@ -74,6 +77,12 @@ type home struct {
// keySent is used to manage underlining menu items
keySent bool

// instanceStarting is true while a background instance start is in progress.
// Prevents double-submission and guards against interacting with a not-yet-started instance.
instanceStarting bool
// startingInstance holds a reference to the instance being started in the background.
startingInstance *session.Instance

// -- UI Components --

// list displays the list of instances
Expand Down Expand Up @@ -180,7 +189,7 @@ func (m *home) Init() tea.Cmd {
time.Sleep(100 * time.Millisecond)
return previewTickMsg{}
},
tickUpdateMetadataCmd,
tickUpdateMetadataCmd(m.list.GetInstances()),
)
}

Expand All @@ -200,26 +209,51 @@ func (m *home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case keyupMsg:
m.menu.ClearKeydown()
return m, nil
case tickUpdateMetadataMessage:
for _, instance := range m.list.GetInstances() {
if !instance.Started() || instance.Paused() {
continue
}
updated, prompt := instance.HasUpdated()
if updated {
instance.SetStatus(session.Running)
case instanceStartDoneMsg:
m.instanceStarting = false
inst := msg.instance
m.startingInstance = nil

if msg.err != nil {
// Start failed — remove the instance from the list and show the error.
m.list.Kill()
return m, tea.Batch(tea.WindowSize(), m.instanceChanged(), m.handleError(msg.err))
}

// Save after successful start.
if err := m.storage.SaveInstances(m.list.GetInstances()); err != nil {
return m, m.handleError(err)
}

if m.promptAfterName {
m.state = statePrompt
m.menu.SetState(ui.StatePrompt)
m.textInputOverlay = overlay.NewTextInputOverlay("Enter prompt", "")
m.promptAfterName = false
} else {
m.showHelpScreen(helpStart(inst), nil)
}

return m, tea.Batch(tea.WindowSize(), m.instanceChanged())
case metadataUpdateDoneMsg:
for _, r := range msg.results {
if r.updated {
r.instance.SetStatus(session.Running)
} else if r.hasPrompt {
r.instance.TapEnter()
} else {
if prompt {
instance.TapEnter()
} else {
instance.SetStatus(session.Ready)
}
r.instance.SetStatus(session.Ready)
}
if err := instance.UpdateDiffStats(); err != nil {
log.WarningLog.Printf("could not update diff stats: %v", err)
if r.diffStats != nil && r.diffStats.Error != nil {
if !strings.Contains(r.diffStats.Error.Error(), "base commit SHA not set") {
log.WarningLog.Printf("could not update diff stats: %v", r.diffStats.Error)
}
r.instance.SetDiffStats(nil)
} else {
r.instance.SetDiffStats(r.diffStats)
}
}
return m, tickUpdateMetadataCmd
return m, tickUpdateMetadataCmd(m.list.GetInstances())
case tea.MouseMsg:
// Handle mouse wheel events for scrolling the diff/preview pane
if msg.Action == tea.MouseActionPress {
Expand Down Expand Up @@ -331,35 +365,25 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
return m, m.handleError(fmt.Errorf("title cannot be empty"))
}

if err := instance.Start(true); err != nil {
m.list.Kill()
m.state = stateDefault
return m, m.handleError(err)
}
// Save after adding new instance
if err := m.storage.SaveInstances(m.list.GetInstances()); err != nil {
return m, m.handleError(err)
}
// Instance added successfully, call the finalizer.
// Set loading status for visual feedback (spinner in the list).
instance.SetStatus(session.Loading)

// Register the instance in the list (call finalizer once).
m.newInstanceFinalizer()
if m.autoYes {
instance.AutoYes = true
}

m.newInstanceFinalizer()
// Track the starting instance so the done handler can finish setup.
m.instanceStarting = true
m.startingInstance = instance

// Transition to default state immediately so the UI stays responsive.
m.state = stateDefault
if m.promptAfterName {
m.state = statePrompt
m.menu.SetState(ui.StatePrompt)
// Initialize the text input overlay
m.textInputOverlay = overlay.NewTextInputOverlay("Enter prompt", "")
m.promptAfterName = false
} else {
m.menu.SetState(ui.StateDefault)
m.showHelpScreen(helpStart(instance), nil)
}
m.menu.SetState(ui.StateDefault)

return m, tea.Batch(tea.WindowSize(), m.instanceChanged())
// Kick off the expensive Start() in a background goroutine.
return m, tea.Batch(tea.WindowSize(), m.instanceChanged(), runInstanceStartCmd(instance))
case tea.KeyRunes:
if runewidth.StringWidth(instance.Title) >= 32 {
return m, m.handleError(fmt.Errorf("title cannot be longer than 32 characters"))
Expand Down Expand Up @@ -469,6 +493,9 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
case keys.KeyHelp:
return m.showHelpScreen(helpTypeGeneral{}, nil)
case keys.KeyPrompt:
if m.instanceStarting {
return m, m.handleError(fmt.Errorf("please wait for the current session to finish starting"))
}
if m.list.NumInstances() >= GlobalInstanceLimit {
return m, m.handleError(
fmt.Errorf("you can't create more than %d instances", GlobalInstanceLimit))
Expand All @@ -490,6 +517,9 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {

return m, nil
case keys.KeyNew:
if m.instanceStarting {
return m, m.handleError(fmt.Errorf("please wait for the current session to finish starting"))
}
if m.list.NumInstances() >= GlobalInstanceLimit {
return m, m.handleError(
fmt.Errorf("you can't create more than %d instances", GlobalInstanceLimit))
Expand Down Expand Up @@ -563,7 +593,7 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
return m, m.confirmAction(message, killAction)
case keys.KeySubmit:
selected := m.list.GetSelectedInstance()
if selected == nil {
if selected == nil || selected.Status == session.Loading {
return m, nil
}

Expand All @@ -586,7 +616,7 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
return m, m.confirmAction(message, pushAction)
case keys.KeyCheckout:
selected := m.list.GetSelectedInstance()
if selected == nil {
if selected == nil || selected.Status == session.Loading {
return m, nil
}

Expand All @@ -612,7 +642,7 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
return m, nil
}
selected := m.list.GetSelectedInstance()
if selected == nil || selected.Paused() || !selected.TmuxAlive() {
if selected == nil || selected.Paused() || selected.Status == session.Loading || !selected.TmuxAlive() {
return m, nil
}
// Show help screen before attaching
Expand All @@ -624,6 +654,7 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
}
<-ch
m.state = stateDefault
m.instanceChanged()
})
return m, nil
default:
Expand Down Expand Up @@ -670,15 +701,70 @@ type hideErrMsg struct{}
// previewTickMsg implements tea.Msg and triggers a preview update
type previewTickMsg struct{}

type tickUpdateMetadataMessage struct{}

type instanceChangedMsg struct{}

// tickUpdateMetadataCmd is the callback to update the metadata of the instances every 500ms. Note that we iterate
// overall the instances and capture their output. It's a pretty expensive operation. Let's do it 2x a second only.
var tickUpdateMetadataCmd = func() tea.Msg {
time.Sleep(500 * time.Millisecond)
return tickUpdateMetadataMessage{}
// instanceMetaResult holds the results of a single instance's metadata update,
// computed in a background goroutine.
type instanceMetaResult struct {
instance *session.Instance
updated bool
hasPrompt bool
diffStats *git.DiffStats
}

// metadataUpdateDoneMsg is sent when the background metadata update completes.
type metadataUpdateDoneMsg struct {
results []instanceMetaResult
}

// instanceStartDoneMsg is sent when the background instance start completes.
type instanceStartDoneMsg struct {
instance *session.Instance
err error
}

// runInstanceStartCmd returns a Cmd that performs the expensive instance.Start(true)
// in a background goroutine so the main event loop stays responsive.
func runInstanceStartCmd(instance *session.Instance) tea.Cmd {
return func() tea.Msg {
err := instance.Start(true)
return instanceStartDoneMsg{instance: instance, err: err}
}
}

// tickUpdateMetadataCmd returns a self-chaining Cmd that sleeps 500ms, then performs
// expensive metadata I/O (tmux capture, git diff) in parallel background goroutines.
// Because it only re-schedules after completing, overlapping ticks are impossible.
func tickUpdateMetadataCmd(instances []*session.Instance) tea.Cmd {
return func() tea.Msg {
time.Sleep(500 * time.Millisecond)

var active []*session.Instance
for _, inst := range instances {
if inst.Started() && !inst.Paused() {
active = append(active, inst)
}
}
if len(active) == 0 {
return metadataUpdateDoneMsg{}
}

results := make([]instanceMetaResult, len(active))
var wg sync.WaitGroup
for idx, inst := range active {
wg.Add(1)
go func(i int, instance *session.Instance) {
defer wg.Done()
r := &results[i]
r.instance = instance
r.updated, r.hasPrompt = instance.HasUpdated()
r.diffStats = instance.ComputeDiff()
}(idx, inst)
}
wg.Wait()

return metadataUpdateDoneMsg{results: results}
}
}

// handleError handles all errors which get bubbled up to the app. sets the error message. We return a callback tea.Cmd that returns a hideErrMsg message
Expand Down
15 changes: 15 additions & 0 deletions session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,21 @@ func (i *Instance) UpdateDiffStats() error {
return nil
}

// ComputeDiff runs the expensive git diff I/O and returns the result without
// mutating instance state. Safe to call from a background goroutine.
func (i *Instance) ComputeDiff() *git.DiffStats {
if !i.started || i.Status == Paused {
return nil
}
return i.gitWorktree.Diff()
}

// SetDiffStats sets the diff statistics on the instance. Should be called from
// the main event loop to avoid data races with View.
func (i *Instance) SetDiffStats(stats *git.DiffStats) {
i.diffStats = stats
}

// GetDiffStats returns the current git diff statistics
func (i *Instance) GetDiffStats() *git.DiffStats {
return i.diffStats
Expand Down
2 changes: 2 additions & 0 deletions ui/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ func (r *InstanceRenderer) Render(i *session.Instance, idx int, selected bool, h
switch i.Status {
case session.Running:
join = fmt.Sprintf("%s ", r.spinner.View())
case session.Loading:
join = fmt.Sprintf("%s ", r.spinner.View())
case session.Ready:
join = readyStyle.Render(readyIcon)
case session.Paused:
Expand Down
4 changes: 3 additions & 1 deletion ui/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ func (p *PreviewPane) UpdateContent(instance *session.Instance) error {

// Always update the preview state with content, even if empty
// This ensures that newly created instances will display their content immediately
if len(content) == 0 && !instance.Started() {
if len(content) == 0 && !instance.Started() && instance.Status == session.Loading {
p.setFallbackState("Starting session...")
} else if len(content) == 0 && !instance.Started() {
p.setFallbackState("Please enter a name for the instance.")
} else {
// Update the preview state with the current content
Expand Down
Loading