Skip to content
Open
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
66 changes: 56 additions & 10 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/charmbracelet/bubbles/spinner"
Expand All @@ -21,9 +23,9 @@ import (
const GlobalInstanceLimit = 10

// Run is the main entrypoint into the application.
func Run(ctx context.Context, program string, autoYes bool) error {
func Run(ctx context.Context, program string, autoYes bool, repoRoot string, showAll bool) error {
p := tea.NewProgram(
newHome(ctx, program, autoYes),
newHome(ctx, program, autoYes, repoRoot, showAll),
tea.WithAltScreen(),
tea.WithMouseCellMotion(), // Mouse scroll
)
Expand Down Expand Up @@ -60,6 +62,14 @@ type home struct {
// appState stores persistent application state like seen help screens
appState config.AppState

// repoRoot is the git repo root for the current working directory
repoRoot string
// showAll disables project-scoped filtering when true
showAll bool
// otherProjectData holds raw InstanceData for sessions not matching the current project,
// so they can be preserved when saving back to disk
otherProjectData []session.InstanceData

// -- State --

// state is the current discrete state of the application
Expand Down Expand Up @@ -94,7 +104,20 @@ type home struct {
confirmationOverlay *overlay.ConfirmationOverlay
}

func newHome(ctx context.Context, program string, autoYes bool) *home {
// instanceMatchesRepo returns true if the given InstanceData belongs to the given repo root.
func instanceMatchesRepo(data session.InstanceData, repoRoot string) bool {
// Check worktree repo path first (primary match)
if data.Worktree.RepoPath != "" {
return data.Worktree.RepoPath == repoRoot
}
// Fall back to checking if the session path starts with the repo root (older sessions)
if data.Path != "" {
return strings.HasPrefix(data.Path, repoRoot)
}
return false
}

func newHome(ctx context.Context, program string, autoYes bool, repoRoot string, showAll bool) *home {
// Load application config
appConfig := config.LoadConfig()

Expand All @@ -108,6 +131,12 @@ func newHome(ctx context.Context, program string, autoYes bool) *home {
os.Exit(1)
}

// Determine the repo name for the list header
var repoName string
if repoRoot != "" && !showAll {
repoName = filepath.Base(repoRoot)
}

h := &home{
ctx: ctx,
spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)),
Expand All @@ -120,18 +149,35 @@ func newHome(ctx context.Context, program string, autoYes bool) *home {
autoYes: autoYes,
state: stateDefault,
appState: appState,
repoRoot: repoRoot,
showAll: showAll,
}
h.list = ui.NewList(&h.spinner, autoYes)
h.list = ui.NewList(&h.spinner, autoYes, repoName)

// Load saved instances
instances, err := storage.LoadInstances()
// Load raw instance data for filtering
allData, err := storage.LoadInstanceData()
if err != nil {
fmt.Printf("Failed to load instances: %v\n", err)
os.Exit(1)
}

// Add loaded instances to the list
for _, instance := range instances {
// Partition into matching and non-matching instances
var matchingData []session.InstanceData
for _, data := range allData {
if showAll || repoRoot == "" || instanceMatchesRepo(data, repoRoot) {
matchingData = append(matchingData, data)
} else {
h.otherProjectData = append(h.otherProjectData, data)
}
}

// Hydrate only matching instances
for _, data := range matchingData {
instance, err := session.FromInstanceData(data)
if err != nil {
fmt.Printf("Failed to create instance %s: %v\n", data.Title, err)
os.Exit(1)
}
// Call the finalizer immediately.
h.list.AddInstance(instance)()
if autoYes {
Expand Down Expand Up @@ -260,7 +306,7 @@ func (m *home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

// Save after successful start
if err := m.storage.SaveInstances(m.list.GetInstances()); err != nil {
if err := m.storage.SaveInstancesWithExtra(m.list.GetInstances(), m.otherProjectData); err != nil {
return m, m.handleError(err)
}
if m.autoYes {
Expand All @@ -286,7 +332,7 @@ func (m *home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

func (m *home) handleQuit() (tea.Model, tea.Cmd) {
if err := m.storage.SaveInstances(m.list.GetInstances()); err != nil {
if err := m.storage.SaveInstancesWithExtra(m.list.GetInstances(), m.otherProjectData); err != nil {
return m, m.handleError(err)
}
return m, tea.Quit
Expand Down
4 changes: 2 additions & 2 deletions app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func TestConfirmationModalStateTransitions(t *testing.T) {
func TestConfirmationModalKeyHandling(t *testing.T) {
// Import needed packages
spinner := spinner.New(spinner.WithSpinner(spinner.MiniDot))
list := ui.NewList(&spinner, false)
list := ui.NewList(&spinner, false, "")

// Create enough of home struct to test handleKeyPress in confirmation state
h := &home{
Expand Down Expand Up @@ -230,7 +230,7 @@ func TestConfirmationMessageFormatting(t *testing.T) {
func TestConfirmationFlowSimulation(t *testing.T) {
// Create a minimal setup
spinner := spinner.New(spinner.WithSpinner(spinner.MiniDot))
list := ui.NewList(&spinner, false)
list := ui.NewList(&spinner, false, "")

// Add test instance
instance, err := session.NewInstance(session.InstanceOptions{
Expand Down
9 changes: 8 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var (
programFlag string
autoYesFlag bool
daemonFlag bool
allFlag bool
rootCmd = &cobra.Command{
Use: "claude-squad",
Short: "Claude Squad - Manage multiple AI agents like Claude Code, Aider, Codex, and Amp.",
Expand All @@ -47,6 +48,11 @@ var (
return fmt.Errorf("error: claude-squad must be run from within a git repository")
}

repoRoot, err := git.FindGitRepoRoot(currentDir)
if err != nil {
return fmt.Errorf("failed to find git repo root: %w", err)
}

cfg := config.LoadConfig()

// Program flag overrides config
Expand All @@ -71,7 +77,7 @@ var (
log.ErrorLog.Printf("failed to stop daemon: %v", err)
}

return app.Run(ctx, program, autoYes)
return app.Run(ctx, program, autoYes, repoRoot, allFlag)
},
}

Expand Down Expand Up @@ -150,6 +156,7 @@ func init() {
"[experimental] If enabled, all instances will automatically accept prompts")
rootCmd.Flags().BoolVar(&daemonFlag, "daemon", false, "Run a program that loads all sessions"+
" and runs autoyes mode on them.")
rootCmd.Flags().BoolVarP(&allFlag, "all", "a", false, "Show sessions from all projects")

// Hide the daemonFlag as it's only for internal use
err := rootCmd.Flags().MarkHidden("daemon")
Expand Down
2 changes: 1 addition & 1 deletion session/git/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func IsGitRepo(path string) bool {
return cmd.Run() == nil
}

func findGitRepoRoot(path string) (string, error) {
func FindGitRepoRoot(path string) (string, error) {
cmd := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion session/git/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func NewGitWorktree(repoPath string, sessionName string) (tree *GitWorktree, bra
absPath = repoPath
}

repoPath, err = findGitRepoRoot(absPath)
repoPath, err = FindGitRepoRoot(absPath)
if err != nil {
return nil, "", err
}
Expand Down
33 changes: 33 additions & 0 deletions session/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,39 @@ func (s *Storage) LoadInstances() ([]*Instance, error) {
return instances, nil
}

// LoadInstanceData loads the raw instance data without hydrating into full Instances.
// This avoids tmux side effects for sessions that will be filtered out.
func (s *Storage) LoadInstanceData() ([]InstanceData, error) {
jsonData := s.state.GetInstances()

var instancesData []InstanceData
if err := json.Unmarshal(jsonData, &instancesData); err != nil {
return nil, fmt.Errorf("failed to unmarshal instances: %w", err)
}

return instancesData, nil
}

// SaveInstancesWithExtra saves the hydrated instances plus extra raw InstanceData
// (from other projects) back to disk, preserving sessions that were filtered out.
func (s *Storage) SaveInstancesWithExtra(instances []*Instance, extra []InstanceData) error {
data := make([]InstanceData, 0)
for _, instance := range instances {
if instance.Started() {
data = append(data, instance.ToInstanceData())
}
}

data = append(data, extra...)

jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal instances: %w", err)
}

return s.state.SaveInstances(jsonData)
}

// DeleteInstance removes an instance from storage
func (s *Storage) DeleteInstance(title string) error {
instances, err := s.LoadInstances()
Expand Down
10 changes: 8 additions & 2 deletions ui/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,21 @@ type List struct {
height, width int
renderer *InstanceRenderer
autoyes bool
// repoName is displayed in the list header when set (project-scoped filtering)
repoName string

// map of repo name to number of instances using it. Used to display the repo name only if there are
// multiple repos in play.
repos map[string]int
}

func NewList(spinner *spinner.Model, autoYes bool) *List {
func NewList(spinner *spinner.Model, autoYes bool, repoName string) *List {
return &List{
items: []*session.Instance{},
renderer: &InstanceRenderer{spinner: spinner},
repos: make(map[string]int),
autoyes: autoYes,
repoName: repoName,
}
}

Expand Down Expand Up @@ -226,7 +229,10 @@ func (r *InstanceRenderer) Render(i *session.Instance, idx int, selected bool, h
}

func (l *List) String() string {
const titleText = " Instances "
titleText := " Instances "
if l.repoName != "" {
titleText = " " + l.repoName + " "
}
const autoYesText = " auto-yes "

// Write the title.
Expand Down
Loading